Financial & BankingParse

Receipt Parser

Parses receipts into markdown .

Ship it with Extend

Live pipeline

a real document, processed end to end · view only
Source documentreceipt1.jpg

Step-by-step

A retail receipt is a point-of-sale transaction document issued by a merchant that records store information, itemized products with prices, tax calculations, payment method, and authorization codes for a customer purchase. This template takes in Retail Receipt and outputs JSON (.json) with structured transaction fields including store metadata, itemized line items, financial totals, and payment confirmation details per the extraction schema by using Extend's Parse primitives.

Input
Retail Receipt
Compatible document types (full list)
.pdf.docx.xlsx.png.jpg.jpeg.tiff.tif.svg.heic.heif.bmp.gif.webp.psd.xls.xltm.xltx.ods.doc.wpd.dotx.odt.pptx.ppt.ppm.csv.txt.html.xml.rtf.lis.md.eml.pcx
Step 1

Parse

Converts the document into clean, layout-aware markdown plus structured blocks with spatial metadata.

InputSource document — PDF, image, spreadsheet, presentation, or scan
Config
chunks[{"id":"chunk_section_4fMbrk","type":"section","blocks":[{"id":"block_1_ocr_NRsDLA","type":"text","object":"block","content":"G","details":{},"polygon":[{"x":57…changed
parseOutputMetadata.finalMimeType"image/jpeg"changed
parseOutputMetadata.originalMimeType"image/jpeg"changed
parseOutputMetadata.pagesnullchanged
OutputMarkdown chunked by page or section, plus typed blocks (text, table, figure) with bounding boxes

You can learn more about Parse configuration in Extend's Parse documentation.

Example code

{
  "name": "Receipt Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              },
              "signatureDetectionEnabled": true
            },
            "tables": {
              "agentic": {
                "enabled": true
              },
              "tableHeaderContinuationEnabled": true
            },
            "figures": {
              "enabled": true
            }
          },
          "chunkingStrategy": {
            "type": "page",
            "options": {}
          }
        }
      }
    }
  ]
}
# Receipt Processing — Extend AI Skill

## What this pipeline does

This pipeline parses retail receipts (point-of-sale printouts) into structured markdown, capturing store information, itemized products with prices, subtotals, taxes, and payment authorization details. It handles variable-length item lists, table-based layouts, and embedded card authentication codes common in physical receipts. Output is markdown with precise bounding boxes for agentic workflows.

## When to use this

- **Expense management systems**: Employees submit receipt images; you parse to extract vendor, date, total, and line items for reimbursement.
- **Retail auditing**: Chain stores need to digitize paper receipts to verify inventory, pricing, and payment records.
- **Financial reconciliation**: Accounting teams match receipts to bank transactions by extracting transaction ID, timestamp, and payment method.
- **POS data extraction**: Legacy point-of-sale systems that print rather than emit JSON; convert to structured data.
- **Multi-store loyalty programs**: Parse Clubcard/loyalty statements printed on receipts to track member points and promotions.

## Processor pipeline

### Step 1: Parse (agentic_ocr mode)
**Processor**: `parseRuns.createAndPoll()`  
**Purpose**: Convert receipt image to markdown with tables and text blocks preserved; enable agentic OCR to handle low-contrast thermal paper and skewed camera angles.  
**Key config**:
- `blockOptions.text.agentic.enabled: true` — use AI-enhanced OCR for blurry/faded thermal receipts
- `blockOptions.tables.agentic.enabled: true` — reconstruct corrupted item tables from context
- `blockOptions.tables.tableHeaderContinuationEnabled: true` — continue table header across pages if receipt is multi-page
- `blockOptions.figures.enabled: true` — preserve QR codes, logos, barcodes as figures for manual review
- `chunkingStrategy: "page"` — one chunk per page; receipts are short so this matches 1:1

**Why**: Receipts are often printed on thermal paper with faded ink and captured at odd angles. Agentic mode leverages LLM-aware OCR to infer missing digits (e.g., `£___.89` → `£2.89` from context). Table reconstruction recovers alignment lost in poor scans.

## TypeScript implementation



## CLI equivalent

```bash
# Step 1: Parse receipt to markdown (agentic OCR for thermal paper)
extend parse receipt.jpg \
  --mode agentic_ocr \
  --output-type markdown \
  --agentic-text \
  --agentic-tables \
  --signatures \
  --figures

# Step 2: Extract structured fields (requires schema.json)
extend extract receipt.jpg --schema receipt-schema.json
```

**receipt-schema.json** (use with `extend extract`):
```json
{
  "type": "object",
  "properties": {
    "store_name": {
      "type": ["string", "null"],
      "description": "Name of the retail store or merchant"
    },
    "transaction_date": {
      "type": ["string", "null"],
      "description": "Transaction date in DD/MM/YY or ISO 8601 format"
    },
    "line_items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "description": {
            "type": ["string", "null"],
            "description": "Product name or description"
          },
          "line_total": {
            "type": ["string", "null"],
            "description": "Total for this line item, e.g. '£2.29' or '2.29'"
          }
        }
      },
      "description": "Array of purchased items with prices"
    },
    "total_amount": {
      "type": ["string", "null"],
      "description": "Total amount due including tax, e.g. '£5.08'"
    },
    "payment_method": {
      "type": ["string", "null"],
      "description": "Payment method: CARD, CASH, VOUCHER, CLUBCARD"
    },
    "card_last_four": {
      "type": ["string", "null"],
      "description": "Last 4 digits of card (e.g. '0938')"
    },
    "authorization_code": {
      "type": ["string", "null"],
      "description": "Payment authorization code from terminal (e.g. '036017')"
    }
  }
}
```

## Schema

The extraction schema captures retail receipt structure end-to-end:

```typescript
z.object({
  // Store & transaction metadata
  store_name: z.string().nullable()
    .describe("Store name—critical for matching receipts to vendor in accounting system"),
  store_phone: z.string().nullable()
    .describe("Store phone for dispute resolution; helps verify location"),
  transaction_date: z.string().nullable()
    .describe("Date in DD/MM/YY or ISO format; essential for matching bank feed timestamps"),
  transaction_time: z.string().nullable()
    .describe("Time HH:MM; helps reconcile multi-transaction days"),

  // Line items—HIGHEST accuracy lever
  line_items: z.array(z.object({
    description: z.string().nullable()
      .describe("Product name. CRITICAL: infer from context if garbled. '2 @ £0.95' → qty=2, unit=0.95, total=1.90"),
    quantity: z.string().nullable()
      .describe("Qty notation: '2 @', '1x', '2 x £1.50', or null if single item. Preserve original format for POS reconciliation."),
    unit_price: extendCurrency()
      .describe("Unit price if shown separately; null if only total shown. Used for inventory audits."),
    line_total: extendCurrency()
      .describe("Line total (qty × unit or shown total). This is the row sum; must not include tax."),
  }))
    .describe("Item array. Why detailed: expense systems require line-level audit trail. If item qty unclear, assume 1."),

  // Totals
  subtotal: extendCurrency()
    .describe("Subtotal BEFORE tax; required for VAT reconciliation and tax liability reports"),
  tax_amount: extendCurrency()
    .describe("Tax/VAT amount; verify against subtotal × tax_rate for compliance audits"),
  total_amount: extendCurrency()
    .describe("Grand total (subtotal + tax). This is what was charged to payment method."),

  // Payment & authorization
  payment_method: z.string().nullable()
    .describe("CARD, CASH, VOUCHER, CHEQUE, CLUBCARD, or other. Directs routing in accounting."),
  card_type: z.string().nullable()
    .describe("Card brand: VISA, MASTERCARD, AMEX, JCB. Helps match card statement lines."),
  card_last_four: z.string().nullable()
    .describe("Last 4 digits masked (e.g. '1234'). Links to bank reconciliation and fraud detection."),
  authorization_code: z.string().nullable()
    .describe("Auth code from card terminal (e.g. '036017'). Proof of authorization; required for disputes."),
  merchant_id: z.string().nullable()
    .describe("Merchant ID from payment processor. Reconciles receipt to processor statement."),
  aid: z.string().nullable()
    .describe("EMV AID from chip reader (e.g. A0000000041010 for Mastercard).
import { ExtendClient, extendCurrency } from "extend-ai";
import { z } from "zod";
import fs from "fs";

const client = new ExtendClient({ token: process.env.EXTEND_API_KEY });

/**
 * Process a receipt image: parse to markdown, then extract structured fields.
 * Handles thermal paper, low contrast, and variable-length item lists.
 */
async function processReceipt(filePath: string) {
  // Convert local file to data URL for SDK
  const fileBuffer = fs.readFileSync(filePath);
  const dataUrl = `data:application/octet-stream;base64,${fileBuffer.toString("base64")}`;

  console.log(`[1/2] Parsing receipt from ${filePath}...`);

  // Step 1: Parse receipt to markdown + bounding boxes
  // Agentic OCR mode handles thermal paper, faded text, skewed images
  const parseRun = await client.parseRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      mode: "agentic_ocr",
      outputType: "markdown",
      blockOptions: {
        text: {
          agentic: { enabled: true },
          signatureDetectionEnabled: true, // Detect cardholder signatures
        },
        tables: {
          agentic: { enabled: true },
          tableHeaderContinuationEnabled: true, // Handle multi-page receipts
        },
        figures: { enabled: true }, // Preserve barcodes, QR codes, logos
      },
      chunkingStrategy: {
        type: "page",
      },
    },
  });

  if (parseRun.status !== "PROCESSED") {
    throw new Error(`Parse failed with status: ${parseRun.status}`);
  }

  // Combine all chunks into single markdown document
  const receiptMarkdown = parseRun.output.chunks
    .map((chunk) => chunk.content)
    .join("\n\n---\n\n");

  console.log(`[1/2] ✓ Parse complete. Receipt length: ${receiptMarkdown.length} chars`);
  console.log(`Sample:\n${receiptMarkdown.substring(0, 300)}...\n`);

  // Step 2: Extract structured receipt fields using Zod schema
  // This pattern is production-ready: fully typed, reusable schema
  console.log(`[2/2] Extracting structured fields...`);

  const receiptSchema = z.object({
    store_name: z.string().nullable().describe("Name of the retail store or merchant"),
    store_phone: z
      .string()
      .nullable()
      .describe("Store phone number if printed on receipt"),
    transaction_date: z
      .string()
      .nullable()
      .describe("Transaction date in format DD/MM/YY or ISO 8601"),
    transaction_time: z.string().nullable().describe("Transaction time in HH:MM format"),
    line_items: z
      .array(
        z.object({
          description: z
            .string()
            .nullable()
            .describe("Product name or description from receipt line"),
          quantity: z
            .string()
            .nullable()
            .describe("Quantity if shown (e.g. '2 @' or '1x'), null if single item"),
          unit_price: extendCurrency().describe("Price per unit if shown separately"),
          line_total: extendCurrency().describe("Total for this line item"),
        })
      )
      .describe(
        "Array of purchased items with prices. Critical: infer missing fields from context (e.g., qty=1 if not stated)"
      ),
    subtotal: extendCurrency().describe("Subtotal before tax"),
    tax_amount: extendCurrency().describe("Tax/VAT amount"),
    total_amount: extendCurrency().describe("Total amount due (subtotal + tax)"),
    payment_method: z
      .string()
      .nullable()
      .describe("Payment method: CARD, CASH, VOUCHER, CLUBCARD, etc."),
    card_type: z.string().nullable().describe("Card type if card payment: VISA, MASTERCARD, AMEX"),
    card_last_four: z
      .string()
      .nullable()
      .describe("Last 4 digits of card number (masked as ****1234)"),
    authorization_code: z
      .string()
      .nullable()
      .describe("Payment authorization/approval code from card terminal"),
    merchant_id: z
      .string()
      .nullable()
      .describe("Merchant ID from payment processor"),
    aid: z
      .string()
      .nullable()
      .describe(
        "Application Identifier (AID) from EMV chip reader, e.g. A0000000041010 for Mastercard"
      ),
    change_due: extendCurrency().describe("Change amount if cash payment"),
    loyalty_program: z
      .string()
      .nullable()
      .describe("Loyalty program name if present (e.g. TESCO CLUBCARD)"),
    loyalty_number: z
      .string()
      .nullable()
      .describe("Loyalty card number (may be partially masked)"),
    loyalty_points_earned: z
      .number()
      .nullable()
      .describe("Loyalty points earned this transaction"),
    loyalty_points_total: z
      .number()
      .nullable()
      .describe("Total loyalty points on account as of receipt date"),
  });

  const extractRun = await client.extractRuns.createAndPoll({
    file: { url: dataUrl },
    config: {
      schema: receiptSchema,
    },
  });

  if (extractRun.status !== "PROCESSED") {
    throw new Error(`Extract failed with status: ${extractRun.status}`);
  }

  const receipt = extractRun.output.value;

  console.log(`[2/2] ✓ Extraction complete.\n`);

  // Pretty print results
  console.log("=== RECEIPT SUMMARY ===");
  console.log(`Store: ${receipt.store_name || "N/A"}`);
  console.log(`Date/Time: ${receipt.transaction_date || "N/A"} ${receipt.transaction_time || ""}`);
  console.log(`\nPayment Method: ${receipt.payment_method || "N/A"}`);
  if (receipt.card_type) {
    console.log(`  Card: ${receipt.card_type} (***${receipt.card_last_four || "****"})`);
    console.log(`  Auth Code: ${receipt.authorization_code || "N/A"}`);
  }
  if (receipt.loyalty_program) {
    console.log(`\nLoyalty: ${receipt.loyalty_program}`);
    console.log(`  Points This Visit: ${receipt.loyalty_points_earned || 0}`);
    console.log(`  Total Points: ${receipt.loyalty_points_total || 0}`);
  }

  console.log(`\n=== ITEMS (${receipt.line_items.length}) ===`);
  for (const item of receipt.line_items) {
    const qty = item.quantity ? `${item.quantity} × ` : "";
    const unitPrice =
      item.unit_price.amount !== null
        ? `£${item.unit_price.amount.toFixed(2)}`
        : "";
    const lineTotal =
      item.line_total.amount !== null
        ? `£${item.line_total.amount.toFixed(2)}`
        : "—";
    console.log(`  ${item.description || "Unknown"} ${qty}${unitPrice} = ${lineTotal}`);
  }

  console.log(`\n=== TOTALS ===`);
  console.log(
    `Subtotal:  £${receipt.subtotal.amount?.toFixed(2) || "0.00"}`
  );
  console.log(
    `Tax:       £${receipt.tax_amount.amount?.toFixed(2) || "0.00"}`
  );
  console.log(
    `TOTAL:     £${receipt.total_amount.amount?.toFixed(2) || "0.00"}`
  );
  if (receipt.change_due && receipt.change_due.amount !== null) {
    console.log(
      `Change:    £${receipt.change_due.amount.toFixed(2)}`
    );
  }

  // Return structured data for downstream processing
  return {
    status: "success",
    markdown: receiptMarkdown,
    receipt,
  };
}

// Main entry point
const args = process.argv.slice(2);
if (args.length === 0) {
  console.error("Usage: npx ts-node solution.ts <receipt-image-path>");
  process.exit(1);
}

processReceipt(args[0]).catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});
import os
import sys
import base64
from extend_ai import Extend
from zod import z

client = Extend(token=os.environ["EXTEND_API_KEY"])


def process_receipt(file_path: str):
    """
    Process a receipt image: parse to markdown, then extract structured fields.
    Handles thermal paper, low contrast, and variable-length item lists.
    """
    # Convert local file to data URL for SDK
    with open(file_path, "rb") as f:
        file_buffer = f.read()
    data_url = f"data:application/octet-stream;base64,{base64.b64encode(file_buffer).decode()}"

    print(f"[1/2] Parsing receipt from {file_path}...")

    # Step 1: Parse receipt to markdown + bounding boxes
    # Agentic OCR mode handles thermal paper, faded text, skewed images
    parse_run = client.parse_runs.create_and_poll(
        file={"url": data_url},
        config={
            "mode": "agentic_ocr",
            "output_type": "markdown",
            "block_options": {
                "text": {
                    "agentic": {"enabled": True},
                    "signature_detection_enabled": True,
                },
                "tables": {
                    "agentic": {"enabled": True},
                    "table_header_continuation_enabled": True,
                },
                "figures": {"enabled": True},
            },
            "chunking_strategy": {
                "type": "page",
            },
        },
    )

    if parse_run.status != "PROCESSED":
        raise Exception(f"Parse failed with status: {parse_run.status}")

    # Combine all chunks into single markdown document
    receipt_markdown = "\n\n---\n\n".join(
        chunk.content for chunk in parse_run.output.chunks
    )

    print(f"[1/2] ✓ Parse complete. Receipt length: {len(receipt_markdown)} chars")
    print(f"Sample:\n{receipt_markdown[:300]}...\n")

    # Step 2: Extract structured receipt fields using schema
    print("[2/2] Extracting structured fields...")

    receipt_schema = {
        "type": "object",
        "properties": {
            "store_name": {
                "type": ["string", "null"],
                "description": "Name of the retail store",
            },
            "store_location": {
                "type": ["string", "null"],
                "description": "Store address including street, city, state, and zip code",
            },
            "store_phone": {
                "type": ["string", "null"],
                "description": "Store contact phone number",
            },
            "transaction_date": {
                "type": ["string", "null"],
                "description": "Date of purchase in MM/DD/YY format",
            },
            "transaction_time": {
                "type": ["string", "null"],
                "description": "Time of purchase in HH:MM:SS format",
            },
            "items": {
                "type": "array",
                "description": "List of purchased items",
                "items": {
                    "type": "object",
                    "properties": {
                        "description": {
                            "type": ["string", "null"],
                            "description": "Item name or description",
                        },
                        "quantity": {
                            "type": ["number", "null"],
                            "description": "Quantity of items purchased",
                        },
                        "unit_price": {
                            "type": ["number", "null"],
                            "description": "Price per unit",
                        },
                    },
                },
            },
            "subtotal": {
                "type": ["number", "null"],
                "description": "Subtotal before tax",
            },
            "tax": {
                "type": ["number", "null"],
                "description": "Total tax amount",
            },
            "total": {
                "type": ["number", "null"],
                "description": "Final total amount due",
            },
            "payment_method": {
                "type": ["string", "null"],
                "description": "Payment method used (e.g., Chase Visa)",
            },
            "approval_code": {
                "type": ["string", "null"],
                "description": "Transaction approval code",
            },
            "transaction_reference": {
                "type": ["string", "null"],
                "description": "Transaction reference or confirmation number",
            },
        },
    }

    extract_run = client.extract_runs.create_and_poll(
        file={"url": data_url},
        config={
            "schema": receipt_schema,
        },
    )

    if extract_run.status != "PROCESSED":
        raise Exception(f"Extract failed with status: {extract_run.status}")

    receipt = extract_run.output.value

    print("[2/2] ✓ Extraction complete.\n")

    # Pretty print results
    print("=== RECEIPT SUMMARY ===")
    print(f"Store: {receipt.get('store_name') or 'N/A'}")
    print(
        f"Location: {receipt.get('store_location') or 'N/A'}"
    )
    print(
        f"Date/Time: {receipt.get('transaction_date') or 'N/A'} {receipt.get('transaction_time') or ''}"
    )
    print(f"\nPayment Method: {receipt.get('payment_method') or 'N/A'}")
    if receipt.get("approval_code"):
        print(f"  Approval Code: {receipt.get('approval_code')}")
    if receipt.get("transaction_reference"):
        print(f"  Reference: {receipt.get('transaction_reference')}")

    items = receipt.get("items", [])
    print(f"\n=== ITEMS ({len(items)}) ===")
    for item in items:
        description = item.get("description") or "Unknown"
        quantity = item.get("quantity")
        unit_price = item.get("unit_price")
        qty_str = f"{quantity} × " if quantity else ""
        price_str = f"${unit_price:.2f}" if unit_price is not None else ""
        print(f"  {description} {qty_str}{price_str}")

    print("\n=== TOTALS ===")
    subtotal = receipt.get("subtotal")
    tax = receipt.get("tax")
    total = receipt.get("total")
    print(f"Subtotal:  ${subtotal:.2f if subtotal is not None else '0.00'}")
    print(f"Tax:       ${tax:.2f if tax is not None else '0.00'}")
    print(f"TOTAL:     ${total:.2f if total is not None else '0.00'}")

    # Return structured data for downstream processing
    return {
        "status": "success",
        "markdown": receipt_markdown,
        "receipt": receipt,
    }


# Main entry point
if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python solution.py <receipt-image-path>")
        sys.exit(1)

    try:
        result = process_receipt(sys.argv[1])
        print("\nProcessing complete!")
    except Exception as err:
        print(f"Error: {err}")
        sys.exit(1)
// This code uses Extend's REST API directly because Extend has no official Java SDK yet.
// It calls https://api.extend.ai endpoints with Bearer token authentication.

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;

public class ReceiptProcessor {

    private static final String API_BASE = "https://api.extend.ai";
    private static final String API_KEY = System.getenv("EXTEND_API_KEY");
    private static final HttpClient httpClient = HttpClient.newHttpClient();

    static class CurrencyValue {
        public Double amount;
        public String currency;

        public CurrencyValue(Double amount, String currency) {
            this.amount = amount;
            this.currency = currency;
        }
    }

    static class LineItem {
        public String description;
        public String quantity;
        public CurrencyValue unit_price;
        public CurrencyValue line_total;
    }

    static class Receipt {
        public String store_name;
        public String store_phone;
        public String transaction_date;
        public String transaction_time;
        public List<LineItem> line_items;
        public CurrencyValue subtotal;
        public CurrencyValue tax_amount;
        public CurrencyValue total_amount;
        public String payment_method;
        public String card_type;
        public String card_last_four;
        public String authorization_code;
        public String merchant_id;
        public String aid;
        public CurrencyValue change_due;
        public String loyalty_program;
        public String loyalty_number;
        public Integer loyalty_points_earned;
        public Integer loyalty_points_total;
    }

    static class ParseRunResponse {
        public String status;
        public ParseOutput output;
    }

    static class ParseOutput {
        public List<Chunk> chunks;
    }

    static class Chunk {
        public String content;
    }

    static class ExtractRunResponse {
        public String status;
        public ExtractOutput output;
    }

    static class ExtractOutput {
        public Receipt value;
    }

    private static String fileToDataUrl(String filePath) throws IOException {
        byte[] fileBytes = Files.readAllBytes(Paths.get(filePath));
        String base64 = Base64.getEncoder().encodeToString(fileBytes);
        return "data:application/octet-stream;base64," + base64;
    }

    private static String makeRequest(String method, String endpoint, String jsonBody)
            throws IOException, InterruptedException {
        HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
                .uri(URI.create(API_BASE + endpoint))
                .header("Authorization", "Bearer " + API_KEY)
                .header("Content-Type", "application/json");

        if ("POST".equals(method)) {
            requestBuilder.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
        } else if ("GET".equals(method)) {
            requestBuilder.GET();
        }

        HttpRequest request = requestBuilder.build();
        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new IOException("API error: " + response.statusCode() + " " + response.body());
        }

        return response.body();
    }

    private static String buildParseConfig(String dataUrl) {
        return "{"
                + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
                + "\"config\":{"
                + "\"mode\":\"agentic_ocr\","
                + "\"outputType\":\"markdown\","
                + "\"blockOptions\":{"
                + "\"text\":{\"agentic\":{\"enabled\":true},\"signatureDetectionEnabled\":true},"
                + "\"tables\":{\"agentic\":{\"enabled\":true},\"tableHeaderContinuationEnabled\":true},"
                + "\"figures\":{\"enabled\":true}"
                + "},"
                + "\"chunkingStrategy\":{\"type\":\"page\"}"
                + "}"
                + "}";
    }

    private static String buildExtractConfig(String dataUrl) {
        return "{"
                + "\"file\":{\"url\":\"" + escapeJson(dataUrl) + "\"},"
                + "\"config\":{"
                + "\"schema\":{"
                + "\"type\":\"object\","
                + "\"properties\":{"
                + "\"store_name\":{\"type\":[\"string\",\"null\"],\"description\":\"Name of the retail store or merchant\"},"
                + "\"store_phone\":{\"type\":[\"string\",\"null\"],\"description\":\"Store phone number if printed on receipt\"},"
                + "\"transaction_date\":{\"type\":[\"string\",\"null\"],\"description\":\"Transaction date in format DD/MM/YY or ISO 8601\"},"
                + "\"transaction_time\":{\"type\":[\"string\",\"null\"],\"description\":\"Transaction time in HH:MM format\"},"
                + "\"line_items\":{\"type\":\"array\",\"description\":\"Array of purchased items with prices\",\"items\":{\"type\":\"object\",\"properties\":{"
                + "\"description\":{\"type\":[\"string\",\"null\"],\"description\":\"Product name or description from receipt line\"},"
                + "\"quantity\":{\"type\":[\"string\",\"null\"],\"description\":\"Quantity if shown\"},"
                + "\"unit_price\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Price per unit if shown separately\"},"
                + "\"line_total\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Total for this line item\"}"
                + "}}},"
                + "\"subtotal\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Subtotal before tax\"},"
                + "\"tax_amount\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Tax/VAT amount\"},"
                + "\"total_amount\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Total amount due\"},"
                + "\"payment_method\":{\"type\":[\"string\",\"null\"],\"description\":\"Payment method: CARD, CASH, VOUCHER, CLUBCARD, etc.\"},"
                + "\"card_type\":{\"type\":[\"string\",\"null\"],\"description\":\"Card type if card payment: VISA, MASTERCARD, AMEX\"},"
                + "\"card_last_four\":{\"type\":[\"string\",\"null\"],\"description\":\"Last 4 digits of card number\"},"
                + "\"authorization_code\":{\"type\":[\"string\",\"null\"],\"description\":\"Payment authorization/approval code\"},"
                + "\"merchant_id\":{\"type\":[\"string\",\"null\"],\"description\":\"Merchant ID from payment processor\"},"
                + "\"aid\":{\"type\":[\"string\",\"null\"],\"description\":\"Application Identifier (AID) from EMV chip reader\"},"
                + "\"change_due\":{\"type\":[\"object\",\"null\"],\"properties\":{\"amount\":{\"type\":[\"number\",\"null\"]},\"currency\":{\"type\":\"string\"}},\"description\":\"Change amount if cash payment\"},"
                + "\"loyalty_program\":{\"type\":[\"string\",\"null\"],\"description\":\"Loyalty program name if present\"},"
                + "\"loyalty_number\":{\"type\":[\"string\",\"null\"],\"description\":\"Loyalty card number\"},"
                + "\"loyalty_points_earned\":{\"type\":[\"number\",\"null\"],\"description\":\"Loyalty points earned this transaction\"},"
                + "\"loyalty_points_total\":{\"type\":[\"number\",\"null\"],\"description\":\"Total loyalty points on account\"}"
                + "}"
                + "}"
                + "}"
                + "}";
    }

    private static String escapeJson(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
    }

    private static ParseRunResponse pollParseRun(String runId) throws IOException, InterruptedException {
        long startTime = System.currentTimeMillis();
        long timeout = 300000; // 5 minutes

        while (System.currentTimeMillis() - startTime < timeout) {
            String response = makeRequest("GET", "/v1/parseRuns/" + runId, null);
            ParseRunResponse parseResp = parseJson(response, ParseRunResponse.class);

            if ("PROCESSED".equals(parseResp.status)) {
                return parseResp;
            } else if ("FAILED".equals(parseResp.status) || "ERROR".equals(parseResp.status)) {
                throw new IOException("Parse run failed with status: " + parseResp.status);
            }

            Thread.sleep(2000);
        }

        throw new IOException("Parse run polling timeout");
    }

    private static ExtractRunResponse pollExtractRun(String runId) throws IOException, InterruptedException {
        long startTime = System.currentTimeMillis();
        long timeout = 300000; // 5 minutes

        while (System.currentTimeMillis() - startTime < timeout) {
            String response = makeRequest("GET", "/v1/extractRuns/" + runId, null);
            ExtractRunResponse extractResp = parseJson(response, ExtractRunResponse.class);

            if ("PROCESSED".equals(extractResp.status)) {
                return extractResp;
            } else if ("FAILED".equals(extractResp.status) || "ERROR".equals(extractResp.status)) {
                throw new IOException("Extract run failed with status: " + extractResp.status);
            }

            Thread.sleep(2000);
        }

        throw new IOException("Extract run polling timeout");
    }

    private static <T> T parseJson(String json, Class<T> clazz) {
        // Simple JSON parsing using reflection and string manipulation
        // For production, consider using a JSON library
        if (clazz == ParseRunResponse.class) {
            ParseRunResponse resp = new ParseRunResponse();
            resp.status = extractJsonString(json, "status");
            resp.output = new ParseOutput();
            resp.output.chunks = new ArrayList<>();
            String chunksStr = extractJsonArray(json, "chunks");
            for (String chunkStr : splitJsonArray(chunksStr)) {
                Chunk chunk = new Chunk();
                chunk.content = extractJsonString(chunkStr, "content");
                resp.output.chunks.add(chunk);
            }
            return (T) resp;
        } else if (clazz == ExtractRunResponse.class) {
            ExtractRunResponse resp = new ExtractRunResponse();
            resp.status = extractJsonString(json, "status");
            resp.output = new ExtractOutput();
            String valueStr = extractJsonObject(json, "value");
            resp.output.value = parseReceipt(valueStr);
            return (T) resp;
        }
        return null;
    }

    private static Receipt parseReceipt(String json) {
        Receipt receipt = new Receipt();
        receipt.store_name = extractJsonString(json, "store_name");
        receipt.store_phone = extractJsonString(json, "store_phone");
        receipt.transaction_date = extractJsonString(json, "transaction_date");
        receipt.transaction_time = extractJsonString(json, "transaction_time");
        receipt.payment_method = extractJsonString(json, "payment_method");
        receipt.card_type = extractJsonString(json, "card_type");
        receipt.card_last_four = extractJsonString(json, "card_last_four");
        receipt.authorization_code = extractJsonString(json, "authorization_code");
        receipt.merchant_id = extractJsonString(json, "merchant_id");
        receipt.aid = extractJsonString(json, "aid");
        receipt.loyalty_program = extractJsonString(json, "loyalty_program");
        receipt.loyalty_number = extractJsonString(json, "loyalty_number");
        receipt.loyalty_points_earned = extractJsonNumber(json, "loyalty_points_earned");
        receipt.loyalty_points_total = extractJsonNumber(json, "loyalty_points_total");

        receipt.subtotal = extractCurrency(json, "subtotal");
        receipt.tax_amount = extractCurrency(json, "tax_amount");
        receipt.total_amount = extractCurrency(json, "total_amount");
        receipt.change_due = extractCurrency(json, "change_due");

        receipt.line_items = new ArrayList<>();
        String itemsStr = extractJsonArray(json, "line_items");
        for (String itemStr : splitJsonArray(itemsStr)) {
            LineItem item = new LineItem();
            item.description = extractJsonString(itemStr, "description");
            item.quantity = extractJsonString(itemStr, "quantity");
            item.unit_price = extractCurrency(itemStr, "unit_price");
            item.line_total = extractCurrency(itemStr, "line_total");
            receipt.line_items.add(item);
        }

        return receipt;
    }

    private static CurrencyValue extractCurrency(String json, String key) {
        String objStr = extractJsonObject(json, key);
        if (objStr == null || objStr.isEmpty()) return new CurrencyValue(null, "GBP");
        Double amount = extractJsonDouble(objStr, "amount");
        String currency = extractJsonString(objStr, "currency");
        if (currency == null) currency = "GBP";
        return new CurrencyValue(amount, currency);
    }

    private static String extractJsonString(String json, String key) {
        String pattern = "\"" + key + "\":\"";
        int idx = json.indexOf(pattern);
        if (idx == -1) return null;
        int start = idx + pattern.length();
        int end = json.indexOf("\"", start);
        if (end == -1) return null;
        return json.substring(start, end).replace("\\\"", "\"");
    }

    private static Double extractJsonDouble(String json, String key) {
        String pattern = "\"" + key + "\":";
        int idx = json.indexOf(pattern);
        if (idx == -1) return null;
        int start = idx + pattern.length();
        int end = start;
        while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.' || json.charAt(end) == '-')) {
            end++;
        }
        if (start == end) return null;
        try {
            return Double.parseDouble(json.substring(start, end));
        } catch (NumberFormatException e) {
            return null;
        }
    }

    private static Integer extractJsonNumber(String json, String key) {
        Double d = extractJsonDouble(json, key);
        return d != null ? d.intValue() : null;
    }

    private static String extractJsonArray(String json, String key) {
        String pattern = "\"" + key + "\":[";
        int idx = json.indexOf(pattern);
        if (idx == -1) return "";
        int start = idx + pattern.length() - 1;
        int depth = 0;
        int end = start;
        for (int i = start; i < json.length(); i++) {
            char c = json.charAt(i);
            if (c == '[' || c == '{') depth++;
            else if (c == ']' || c == '}') depth--;
            if (depth == 0 && c == ']') {
                end = i + 1;
                break;
            }
        }
        return json.substring(start, end);
    }

    private static String extractJsonObject(String json, String key) {
        String pattern = "\"" + key + "\":{";
        int idx = json.indexOf(pattern);
        if (idx == -1) {
            pattern = "\"" + key + "\":null";
            if (json.indexOf(pattern) != -1) return "";
            return null;
        }
        int start = idx + pattern.length() - 1;
        int depth = 0;
        int end = start;
        for (int i = start; i < json.length(); i++) {
            char c = json.charAt(i);
            if (c == '{') depth++;
            else if (c == '}') depth--;
            if (depth == 0 && c == '}') {
                end = i + 1;
                break;
            }
        }
        return json.substring(start, end);
    }

    private static List<String> splitJsonArray(String arrayStr) {
        List<String> items = new ArrayList<>();
        int depth = 0;
        int start = 1;
        for (int i = 1; i < arrayStr.length() - 1; i++) {
            char c = arrayStr.charAt(i);
            if (c == '{' || c == '[') depth++;
            else if (c == '}' || c == ']') depth--;
            else if (c == ',' && depth == 0) {
                items.add(arrayStr.substring(start, i).trim());
                start = i + 1;
            }
        }
        if (start < arrayStr.length() - 1) {
            items.add(arrayStr.substring(start, arrayStr.length() - 1).trim());
        }
        return items;
    }

    public static void processReceipt(String filePath) throws IOException, InterruptedException {
        String dataUrl = fileToDataUrl(filePath);

        System.out.println("[1/2] Parsing receipt from " + filePath + "...");

        String parseConfig = buildParseConfig(dataUrl);
        String parseResponse = makeRequest("POST", "/v1/parseRuns", parseConfig);
        String runId = extractJsonString(parseResponse, "id");

        ParseRunResponse parseRun = pollParseRun(runId);

        if (!"PROCESSED".equals(parseRun.status)) {
            throw new IOException("Parse failed with status: " + parseRun.status);
        }

        StringBuilder receiptMarkdown = new StringBuilder();
        for (int i = 0; i < parseRun.output.chunks.size(); i++) {
            if (i > 0) receiptMarkdown.append("\n\n---\n\n");
            receiptMarkdown.append(parseRun.output.chunks.get(i).content);
        }

        System.out.println("[1/2] ✓ Parse complete. Receipt length: " + receiptMarkdown.length() + " chars");
        System.out.println("Sample:\n" + receiptMarkdown.substring(0, Math.min(300, receiptMarkdown.length())) + "...\n");

        System.out.println("[2/2] Extracting structured fields...");

        String extractConfig = buildExtractConfig(dataUrl);
        String extractResponse = makeRequest("POST", "/v1/extractRuns", extractConfig);
        String extractRunId = extractJsonString(extractResponse, "id");

        ExtractRunResponse extractRun = pollExtractRun(extractRunId);

        if (!"PROCESSED".equals(extractRun.status)) {
            throw new IOException("Extract failed with status: " + extractRun.status);
        }

        Receipt receipt = extractRun.output.value;

        System.out.println("[2/2] ✓ Extraction complete.\n");

        System.out.println("=== RECEIPT SUMMARY ===");
        System.out.println("Store: " + (receipt.store_name != null ? receipt.store_name : "N/A"));
        System.out.println("Date/Time: " + (receipt.transaction_date != null ? receipt.transaction_date : "N/A") + " " + (receipt.transaction_time != null ? receipt.transaction_time : ""));
        System.out.println("\nPayment Method: " + (receipt.payment_method != null ? receipt.payment_method : "N/A"));
        if (receipt.card_type != null) {
            System.out.println("  Card: " + receipt.card_type + " (***" + (receipt.card_last_four != null ? receipt.card_last_four : "****") + ")");
            System.out.println("  Auth Code: " + (receipt.authorization_code != null ? receipt.authorization_code : "N/A"));
        }
        if (receipt.loyalty_program != null) {
            System.out.println("\nLoyalty: " + receipt.loyalty_program);
            System.out.println("  Points This Visit: " + (receipt.loyalty_points_earned != null ? receipt.loyalty_points_earned : 0));
            System.out.println("  Total Points: " + (receipt.loyalty_points_total != null ? receipt.loyalty_points_total : 0));
        }

        System.out.println("\n=== ITEMS (" + receipt.line_items.size() + ") ===");
        for (LineItem item : receipt.line_items) {
            String qty = item.quantity != null ? item.quantity + " × " : "";
            String unitPrice = item.unit_price != null && item.unit_price.amount != null
                    ? "£" + String.format("%.2f", item.unit_price.amount)
                    : "";
            String lineTotal = item.line_total != null && item.line_total.amount != null
                    ? "£" + String.format("%.2f", item.line_total.amount)
                    : "—";
            System.out.println("  " + (item.description != null ? item.description : "Unknown") + " " + qty + unitPrice + " = " + lineTotal);
        }

        System.out.println("\n=== TOTALS ===");
        System.out.println("Subtotal:  £" + (receipt.subtotal != null && receipt.subtotal.amount != null
                ? String.format("%.2f", receipt.subtotal.amount)
                : "0.00"));
        System.out.println("Tax:       £" + (receipt.tax_amount != null && receipt.tax_amount.amount != null
                ? String.format("%.2f", receipt.tax_amount.amount)
                : "0.00"));
        System.out.println("TOTAL:     £" + (receipt.total_amount != null && receipt.total_amount.amount != null
                ? String.format("%.2f", receipt.total_amount.amount)
                : "0.00"));
        if (receipt.change_due != null && receipt.change_due.amount != null) {
            System.out.println("Change:    £" + String.format("%.2f", receipt.change_due.amount));
        }
    }

    public static void main(String[] args) {
        if (args.length == 0) {
            System.err.println("Usage: java ReceiptProcessor <receipt-image-path>");
            System.exit(1);
        }

        try {
            processReceipt(args[0]);
        } catch (Exception e) {
            System.err.println("Error: " + e.getMessage());
            e.printStackTrace();
            System.exit(1);
        }
    }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// It calls https://api.extend.ai endpoints with standard net/http and encoding/json.

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const extendAPIBase = "https://api.extend.ai"

type Client struct {
	token string
}

func NewClient(token string) *Client {
	return &Client{token: token}
}

func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
	url := extendAPIBase + path
	var reqBody io.Reader
	if body != nil {
		data, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reqBody = bytes.NewReader(data)
	}

	req, err := http.NewRequest(method, url, reqBody)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+c.token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
	}

	return respBody, nil
}

type ParseRunRequest struct {
	File   FileInput      `json:"file"`
	Config ParseConfig    `json:"config"`
}

type FileInput struct {
	URL string `json:"url"`
}

type ParseConfig struct {
	Mode              string       `json:"mode"`
	OutputType        string       `json:"outputType"`
	BlockOptions      BlockOptions `json:"blockOptions"`
	ChunkingStrategy  ChunkingStrategy `json:"chunkingStrategy"`
}

type BlockOptions struct {
	Text   TextBlock   `json:"text"`
	Tables TablesBlock `json:"tables"`
	Figures FiguresBlock `json:"figures"`
}

type TextBlock struct {
	Agentic                    AgenticConfig `json:"agentic"`
	SignatureDetectionEnabled  bool          `json:"signatureDetectionEnabled"`
}

type TablesBlock struct {
	Agentic                       AgenticConfig `json:"agentic"`
	TableHeaderContinuationEnabled bool          `json:"tableHeaderContinuationEnabled"`
}

type FiguresBlock struct {
	Enabled bool `json:"enabled"`
}

type AgenticConfig struct {
	Enabled bool `json:"enabled"`
}

type ChunkingStrategy struct {
	Type string `json:"type"`
}

type ParseRunResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Output struct {
		Chunks []struct {
			Content string `json:"content"`
		} `json:"chunks"`
	} `json:"output"`
}

type ExtractRunRequest struct {
	File   FileInput   `json:"file"`
	Config ExtractConfig `json:"config"`
}

type ExtractConfig struct {
	Schema map[string]interface{} `json:"schema"`
}

type ExtractRunResponse struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	Output struct {
		Value ReceiptData `json:"value"`
	} `json:"output"`
}

type ReceiptData struct {
	StoreName              *string      `json:"store_name"`
	StoreLocation          *string      `json:"store_location"`
	StorePhone             *string      `json:"store_phone"`
	TransactionDate        *string      `json:"transaction_date"`
	TransactionTime        *string      `json:"transaction_time"`
	Items                  []LineItem   `json:"items"`
	Subtotal               *float64     `json:"subtotal"`
	Tax                    *float64     `json:"tax"`
	Total                  *float64     `json:"total"`
	PaymentMethod          *string      `json:"payment_method"`
	ApprovalCode           *string      `json:"approval_code"`
	TransactionReference   *string      `json:"transaction_reference"`
}

type LineItem struct {
	Description *string  `json:"description"`
	Quantity    *float64 `json:"quantity"`
	UnitPrice   *float64 `json:"unit_price"`
}

func (c *Client) createAndPollParseRun(req *ParseRunRequest) (*ParseRunResponse, error) {
	data, err := c.do("POST", "/v1/parse-runs", req)
	if err != nil {
		return nil, err
	}

	var resp ParseRunResponse
	if err := json.Unmarshal(data, &resp); err != nil {
		return nil, err
	}

	runID := resp.ID
	for {
		data, err := c.do("GET", "/v1/parse-runs/"+runID, nil)
		if err != nil {
			return nil, err
		}

		if err := json.Unmarshal(data, &resp); err != nil {
			return nil, err
		}

		if resp.Status == "PROCESSED" || resp.Status == "FAILED" {
			return &resp, nil
		}

		time.Sleep(2 * time.Second)
	}
}

func (c *Client) createAndPollExtractRun(req *ExtractRunRequest) (*ExtractRunResponse, error) {
	data, err := c.do("POST", "/v1/extract-runs", req)
	if err != nil {
		return nil, err
	}

	var resp ExtractRunResponse
	if err := json.Unmarshal(data, &resp); err != nil {
		return nil, err
	}

	runID := resp.ID
	for {
		data, err := c.do("GET", "/v1/extract-runs/"+runID, nil)
		if err != nil {
			return nil, err
		}

		if err := json.Unmarshal(data, &resp); err != nil {
			return nil, err
		}

		if resp.Status == "PROCESSED" || resp.Status == "FAILED" {
			return &resp, nil
		}

		time.Sleep(2 * time.Second)
	}
}

func processReceipt(filePath string) (map[string]interface{}, error) {
	apiKey := os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("EXTEND_API_KEY environment variable not set")
	}

	client := NewClient(apiKey)

	fileBuffer, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	dataURL := "data:application/octet-stream;base64," + base64.StdEncoding.EncodeToString(fileBuffer)

	fmt.Printf("[1/2] Parsing receipt from %s...\n", filePath)

	parseReq := &ParseRunRequest{
		File: FileInput{URL: dataURL},
		Config: ParseConfig{
			Mode:       "agentic_ocr",
			OutputType: "markdown",
			BlockOptions: BlockOptions{
				Text: TextBlock{
					Agentic:                   AgenticConfig{Enabled: true},
					SignatureDetectionEnabled: true,
				},
				Tables: TablesBlock{
					Agentic:                        AgenticConfig{Enabled: true},
					TableHeaderContinuationEnabled: true,
				},
				Figures: FiguresBlock{Enabled: true},
			},
			ChunkingStrategy: ChunkingStrategy{Type: "page"},
		},
	}

	parseRun, err := client.createAndPollParseRun(parseReq)
	if err != nil {
		return nil, err
	}

	if parseRun.Status != "PROCESSED" {
		return nil, fmt.Errorf("parse failed with status: %s", parseRun.Status)
	}

	var receiptMarkdown string
	for i, chunk := range parseRun.Output.Chunks {
		if i > 0 {
			receiptMarkdown += "\n\n---\n\n"
		}
		receiptMarkdown += chunk.Content
	}

	fmt.Printf("[1/2] ✓ Parse complete. Receipt length: %d chars\n", len(receiptMarkdown))
	if len(receiptMarkdown) > 300 {
		fmt.Printf("Sample:\n%s...\n\n", receiptMarkdown[:300])
	}

	fmt.Println("[2/2] Extracting structured fields...")

	receiptSchema := map[string]interface{}{
		"type": "object",
		"properties": map[string]interface{}{
			"store_name": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Name of the retail store",
			},
			"store_location": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Store address including street, city, state, and zip code",
			},
			"store_phone": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Store contact phone number",
			},
			"transaction_date": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Date of purchase in MM/DD/YY format",
			},
			"transaction_time": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Time of purchase in HH:MM:SS format",
			},
			"items": map[string]interface{}{
				"type":        "array",
				"description": "List of purchased items",
				"items": map[string]interface{}{
					"type": "object",
					"properties": map[string]interface{}{
						"description": map[string]interface{}{
							"type":        []string{"string", "null"},
							"description": "Item name or description",
						},
						"quantity": map[string]interface{}{
							"type":        []string{"number", "null"},
							"description": "Quantity of items purchased",
						},
						"unit_price": map[string]interface{}{
							"type":        []string{"number", "null"},
							"description": "Price per unit",
						},
					},
				},
			},
			"subtotal": map[string]interface{}{
				"type":        []string{"number", "null"},
				"description": "Subtotal before tax",
			},
			"tax": map[string]interface{}{
				"type":        []string{"number", "null"},
				"description": "Total tax amount",
			},
			"total": map[string]interface{}{
				"type":        []string{"number", "null"},
				"description": "Final total amount due",
			},
			"payment_method": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Payment method used (e.g., Chase Visa)",
			},
			"approval_code": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Transaction approval code",
			},
			"transaction_reference": map[string]interface{}{
				"type":        []string{"string", "null"},
				"description": "Transaction reference or confirmation number",
			},
		},
	}

	extractReq := &ExtractRunRequest{
		File: FileInput{URL: dataURL},
		Config: ExtractConfig{
			Schema: receiptSchema,
		},
	}

	extractRun, err := client.createAndPollExtractRun(extractReq)
	if err != nil {
		return nil, err
	}

	if extractRun.Status != "PROCESSED" {
		return nil, fmt.Errorf("extract failed with status: %s", extractRun.Status)
	}

	receipt := extractRun.Output.Value

	fmt.Println("[2/2] ✓ Extraction complete.\n")

	fmt.Println("=== RECEIPT SUMMARY ===")
	storeName := "N/A"
	if receipt.StoreName != nil {
		storeName = *receipt.StoreName
	}
	fmt.Printf("Store: %s\n", storeName)

	dateTime := "N/A"
	if receipt.TransactionDate != nil {
		dateTime = *receipt.TransactionDate
		if receipt.TransactionTime != nil {
			dateTime += " " + *receipt.TransactionTime
		}
	}
	fmt.Printf("Date/Time: %s\n", dateTime)

	paymentMethod := "N/A"
	if receipt.PaymentMethod != nil {
		paymentMethod = *receipt.PaymentMethod
	}
	fmt.Printf("\nPayment Method: %s\n", paymentMethod)

	if receipt.ApprovalCode != nil {
		fmt.Printf("  Auth Code: %s\n", *receipt.ApprovalCode)
	}

	fmt.Printf("\n=== ITEMS (%d) ===\n", len(receipt.Items))
	for _, item := range receipt.Items {
		desc := "Unknown"
		if item.Description != nil {
			desc = *item.Description
		}

		qty := ""
		if item.Quantity != nil {
			qty = fmt.Sprintf("%.0f × ", *item.Quantity)
		}

		unitPrice := ""
		if item.UnitPrice != nil {
			unitPrice = fmt.Sprintf("£%.2f", *item.UnitPrice)
		}

		fmt.Printf("  %s %s%s\n", desc, qty, unitPrice)
	}

	fmt.Println("\n=== TOTALS ===")
	subtotal := "0.00"
	if receipt.Subtotal != nil {
		subtotal = fmt.Sprintf("%.2f", *receipt.Subtotal)
	}
	fmt.Printf("Subtotal:  £%s\n", subtotal)

	tax := "0.00"
	if receipt.Tax != nil {
		tax = fmt.Sprintf("%.2f", *receipt.Tax)
	}
	fmt.Printf("Tax:       £%s\n", tax)

	total := "0.00"
	if receipt.Total != nil {
		total = fmt.Sprintf("%.2f", *receipt.Total)
	}
	fmt.Printf("TOTAL:     £%s\n", total)

	return map[string]interface{}{
		"status":   "success",
		"markdown": receiptMarkdown,
		"receipt":  receipt,
	}, nil
}

func main() {
	flag.Parse()
	args := flag.Args()

	if len(args) == 0 {
		fmt.Fprintf(os.Stderr, "Usage: %s <receipt-image-path>\n", os.Args[0])
		os.Exit(1)
	}

	result, err := processReceipt(args[0])
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}

	data, _ := json.MarshalIndent(result, "", "  ")
	fmt.Println(string(data))
}
// Deploy the "Receipt" pipeline to YOUR Extend account.
//
// The workflow below is fully self-contained — every EXTRACT/CLASSIFY/SPLIT
// step carries its extractor/classifier/splitter config INLINE, so this is a
// single API call. No processors to create or wire up beforehand.
// Idempotent: the created workflow id is cached in .extend/receipt-parser.json,
// so re-running updates the existing workflow instead of duplicating it.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   npx tsx provision.ts
//
// Generated by doc1 (template: receipt-parser).

import fs from "node:fs";
import path from "node:path";

const API = "https://api.extend.ai";
const VERSION = "2026-02-09";
const API_KEY = process.env.EXTEND_API_KEY;
if (!API_KEY) { console.error("Set EXTEND_API_KEY first."); process.exit(1); }

const STATE_DIR = path.join(process.cwd(), ".extend");
const STATE_FILE = path.join(STATE_DIR, "receipt-parser.json");

type State = { workflowId?: string };
const state: State = fs.existsSync(STATE_FILE)
  ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
  : {};
function saveState() {
  fs.mkdirSync(STATE_DIR, { recursive: true });
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
}

async function api(method: string, pathName: string, body?: unknown) {
  const res = await fetch(API + pathName, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "x-extend-api-version": VERSION,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${method} ${pathName} failed (${res.status}): ${JSON.stringify(data).slice(0, 300)}`);
  return data;
}

// ── Workflow definition — extractor/classifier/splitter configs inline ──────
const WORKFLOW = {
  "name": "Receipt Processing Pipeline",
  "steps": [
    {
      "name": "startTrigger1",
      "type": "TRIGGER",
      "next": [
        {
          "step": "parse1"
        }
      ]
    },
    {
      "name": "parse1",
      "type": "PARSE",
      "config": {
        "parseConfig": {
          "blockOptions": {
            "text": {
              "agentic": {
                "enabled": true
              },
              "signatureDetectionEnabled": true
            },
            "tables": {
              "agentic": {
                "enabled": true
              },
              "tableHeaderContinuationEnabled": true
            },
            "figures": {
              "enabled": true
            }
          },
          "chunkingStrategy": {
            "type": "page",
            "options": {}
          }
        }
      }
    }
  ]
};

async function main() {
  console.log(`Deploying "${WORKFLOW.name}"…`);

  if (state.workflowId) {
    console.log(`✓ workflow already provisioned (${state.workflowId}) — updating steps`);
    await api("POST", `/workflows/${state.workflowId}`, { steps: WORKFLOW.steps });
  } else {
    // Reuse an existing workflow with the same name if one exists (e.g. a
    // previous run's state file was lost) instead of creating a duplicate.
    try {
      const list = await api("GET", `/workflows?name=${encodeURIComponent(WORKFLOW.name)}`);
      const items = (list.data ?? list.items ?? []) as Array<{ name?: string; id?: string }>;
      const existing = items.find((x) => x.name === WORKFLOW.name);
      if (existing?.id) {
        state.workflowId = existing.id; saveState();
        console.log(`✓ workflow "${WORKFLOW.name}" found in your account (${existing.id}) — updating steps`);
        await api("POST", `/workflows/${existing.id}`, { steps: WORKFLOW.steps });
      }
    } catch { /* lookup is best-effort; fall through to create */ }

    if (!state.workflowId) {
      const created = await api("POST", "/workflows", WORKFLOW);
      const wfId = created.id ?? created.workflow?.id;
      if (!wfId) throw new Error("Could not read created workflow id from response");
      state.workflowId = wfId; saveState();
      console.log(`+ created workflow (${wfId})`);
    }
  }

  // Deploy the current draft as a new version so the workflow is runnable —
  // best-effort: some accounts/plans may not require this explicit step.
  await api("POST", `/workflows/${state.workflowId}/versions`, {}).catch(() => {});

  console.log("\nDone. Run documents through it with:");
  console.log(`  POST ${API}/workflow_runs  { workflow: { id: "${state.workflowId}" }, file: { url: "https://…" } }`);
  console.log("Or open the workflow in the Extend dashboard to review and deploy it.");
}

main().catch((e) => { console.error(e.message ?? e); process.exit(1); });
import os
import json
import sys
from pathlib import Path
from extend_ai import Extend

API_KEY = os.environ.get("EXTEND_API_KEY")
if not API_KEY:
    print("Set EXTEND_API_KEY first.", file=sys.stderr)
    sys.exit(1)

STATE_DIR = Path.cwd() / ".extend"
STATE_FILE = STATE_DIR / "receipt-parser.json"

state = {}
if STATE_FILE.exists():
    state = json.loads(STATE_FILE.read_text())

def save_state():
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2))

# Workflow definition — extractor/classifier/splitter configs inline
WORKFLOW = {
    "name": "Receipt Processing Pipeline",
    "steps": [
        {
            "name": "startTrigger1",
            "type": "TRIGGER",
            "next": [
                {
                    "step": "parse1"
                }
            ]
        },
        {
            "name": "parse1",
            "type": "PARSE",
            "config": {
                "parseConfig": {
                    "blockOptions": {
                        "text": {
                            "agentic": {
                                "enabled": True
                            },
                            "signatureDetectionEnabled": True
                        },
                        "tables": {
                            "agentic": {
                                "enabled": True
                            },
                            "tableHeaderContinuationEnabled": True
                        },
                        "figures": {
                            "enabled": True
                        }
                    },
                    "chunkingStrategy": {
                        "type": "page",
                        "options": {}
                    }
                }
            }
        }
    ]
}

def main():
    client = Extend(token=API_KEY)
    
    print(f'Deploying "{WORKFLOW["name"]}…"')
    
    if state.get("workflowId"):
        workflow_id = state["workflowId"]
        print(f"✓ workflow already provisioned ({workflow_id}) — updating steps")
        client.workflows.update(id=workflow_id, steps=WORKFLOW["steps"])
    else:
        # Reuse an existing workflow with the same name if one exists
        existing_id = None
        try:
            workflows = client.workflows.list(name=WORKFLOW["name"])
            items = workflows.data if hasattr(workflows, "data") else (workflows.items if hasattr(workflows, "items") else [])
            for item in items:
                if item.get("name") == WORKFLOW["name"]:
                    existing_id = item.get("id")
                    break
            
            if existing_id:
                state["workflowId"] = existing_id
                save_state()
                print(f'✓ workflow "{WORKFLOW["name"]}" found in your account ({existing_id}) — updating steps')
                client.workflows.update(id=existing_id, steps=WORKFLOW["steps"])
        except Exception:
            # lookup is best-effort; fall through to create
            pass
        
        if not state.get("workflowId"):
            created = client.workflows.create(**WORKFLOW)
            workflow_id = created.id if hasattr(created, "id") else (created.workflow.id if hasattr(created, "workflow") else None)
            if not workflow_id:
                raise ValueError("Could not read created workflow id from response")
            state["workflowId"] = workflow_id
            save_state()
            print(f"+ created workflow ({workflow_id})")
    
    # Deploy the current draft as a new version so the workflow is runnable
    try:
        client.workflows.create_version(id=state["workflowId"])
    except Exception:
        # best-effort: some accounts/plans may not require this explicit step
        pass
    
    print("\nDone. Run documents through it with:")
    print(f'  POST https://api.extend.ai/workflow_runs  {{ "workflow": {{ "id": "{state["workflowId"]}" }}, "file": {{ "url": "https://…" }} }}')
    print("Or open the workflow in the Extend dashboard to review and deploy it.")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)
// This code calls Extend's REST API directly using only Java's built-in
// java.net.http.HttpClient, because Extend has no official Java SDK yet.
// The API calls mirror the exact endpoints and JSON shapes the TypeScript
// reference SDK uses.

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class ReceiptProvision {
  private static final String API = "https://api.extend.ai";
  private static final String VERSION = "2026-02-09";
  private static final String API_KEY = System.getenv("EXTEND_API_KEY");
  private static final Path STATE_DIR = Paths.get(System.getProperty("user.dir"), ".extend");
  private static final Path STATE_FILE = STATE_DIR.resolve("receipt-parser.json");

  private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();

  static class State {
    String workflowId;
  }

  private static State state = new State();

  public static void main(String[] args) throws Exception {
    if (API_KEY == null || API_KEY.isEmpty()) {
      System.err.println("Set EXTEND_API_KEY first.");
      System.exit(1);
    }

    loadState();

    Map<String, Object> workflow = buildWorkflow();
    String workflowName = (String) workflow.get("name");

    System.out.println("Deploying \"" + workflowName + "\"…");

    if (state.workflowId != null && !state.workflowId.isEmpty()) {
      System.out.println("✓ workflow already provisioned (" + state.workflowId + ") — updating steps");
      List<Map<String, Object>> steps = (List<Map<String, Object>>) workflow.get("steps");
      api("POST", "/workflows/" + state.workflowId, Map.of("steps", steps));
    } else {
      try {
        String encodedName = URLEncoder.encode(workflowName, StandardCharsets.UTF_8);
        Map<String, Object> listResponse = api("GET", "/workflows?name=" + encodedName, null);
        List<Map<String, Object>> items = (List<Map<String, Object>>) listResponse.getOrDefault("data",
            listResponse.getOrDefault("items", List.of()));

        Map<String, Object> existing = null;
        for (Map<String, Object> item : items) {
          if (workflowName.equals(item.get("name"))) {
            existing = item;
            break;
          }
        }

        if (existing != null && existing.get("id") != null) {
          String existingId = (String) existing.get("id");
          state.workflowId = existingId;
          saveState();
          System.out.println("✓ workflow \"" + workflowName + "\" found in your account (" + existingId
              + ") — updating steps");
          List<Map<String, Object>> steps = (List<Map<String, Object>>) workflow.get("steps");
          api("POST", "/workflows/" + existingId, Map.of("steps", steps));
        }
      } catch (Exception e) {
        // lookup is best-effort; fall through to create
      }

      if (state.workflowId == null || state.workflowId.isEmpty()) {
        Map<String, Object> created = api("POST", "/workflows", workflow);
        String wfId = (String) created.get("id");
        if (wfId == null) {
          Map<String, Object> workflowObj = (Map<String, Object>) created.get("workflow");
          if (workflowObj != null) {
            wfId = (String) workflowObj.get("id");
          }
        }
        if (wfId == null) {
          throw new Exception("Could not read created workflow id from response");
        }
        state.workflowId = wfId;
        saveState();
        System.out.println("+ created workflow (" + wfId + ")");
      }
    }

    try {
      api("POST", "/workflows/" + state.workflowId + "/versions", Map.of());
    } catch (Exception e) {
      // best-effort: some accounts/plans may not require this explicit step
    }

    System.out.println("\nDone. Run documents through it with:");
    System.out.println("  POST " + API + "/workflow_runs  { workflow: { id: \"" + state.workflowId
        + "\" }, file: { url: \"https://…\" } }");
    System.out.println("Or open the workflow in the Extend dashboard to review and deploy it.");
  }

  private static Map<String, Object> buildWorkflow() {
    Map<String, Object> workflow = new LinkedHashMap<>();
    workflow.put("name", "Receipt Processing Pipeline");

    Map<String, Object> step1 = new LinkedHashMap<>();
    step1.put("name", "startTrigger1");
    step1.put("type", "TRIGGER");
    step1.put("next", List.of(Map.of("step", "parse1")));

    Map<String, Object> blockOptions = new LinkedHashMap<>();
    Map<String, Object> textBlock = new LinkedHashMap<>();
    textBlock.put("agentic", Map.of("enabled", true));
    textBlock.put("signatureDetectionEnabled", true);
    blockOptions.put("text", textBlock);

    Map<String, Object> tablesBlock = new LinkedHashMap<>();
    tablesBlock.put("agentic", Map.of("enabled", true));
    tablesBlock.put("tableHeaderContinuationEnabled", true);
    blockOptions.put("tables", tablesBlock);

    blockOptions.put("figures", Map.of("enabled", true));

    Map<String, Object> parseConfig = new LinkedHashMap<>();
    parseConfig.put("blockOptions", blockOptions);
    parseConfig.put("chunkingStrategy", Map.of("type", "page", "options", Map.of()));

    Map<String, Object> config = new LinkedHashMap<>();
    config.put("parseConfig", parseConfig);

    Map<String, Object> step2 = new LinkedHashMap<>();
    step2.put("name", "parse1");
    step2.put("type", "PARSE");
    step2.put("config", config);

    workflow.put("steps", List.of(step1, step2));

    return workflow;
  }

  private static Map<String, Object> api(String method, String pathName, Map<String, Object> body)
      throws Exception {
    String url = API + pathName;
    String bodyJson = body != null ? toJson(body) : null;

    HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(url))
        .header("Authorization", "Bearer " + API_KEY).header("x-extend-api-version", VERSION);

    if (method.equals("POST")) {
      requestBuilder.POST(HttpRequest.BodyPublishers.ofString(bodyJson != null ? bodyJson : "{}"));
      requestBuilder.header("Content-Type", "application/json");
    } else if (method.equals("GET")) {
      requestBuilder.GET();
    }

    HttpRequest request = requestBuilder.build();
    HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

    Map<String, Object> data = parseJson(response.body());

    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      String errorMsg = toJson(data);
      if (errorMsg.length() > 300) {
        errorMsg = errorMsg.substring(0, 300);
      }
      throw new Exception(method + " " + pathName + " failed (" + response.statusCode() + "): " + errorMsg);
    }

    return data;
  }

  private static void loadState() throws IOException {
    if (Files.exists(STATE_FILE)) {
      String content = Files.readString(STATE_FILE);
      Map<String, Object> parsed = parseJson(content);
      if (parsed.containsKey("workflowId")) {
        state.workflowId = (String) parsed.get("workflowId");
      }
    }
  }

  private static void saveState() throws IOException {
    Files.createDirectories(STATE_DIR);
    Map<String, Object> stateMap = new LinkedHashMap<>();
    if (state.workflowId != null) {
      stateMap.put("workflowId", state.workflowId);
    }
    Files.writeString(STATE_FILE, toJson(stateMap));
  }

  private static String toJson(Object obj) {
    if (obj == null) {
      return "null";
    }
    if (obj instanceof String) {
      return "\"" + escapeJson((String) obj) + "\"";
    }
    if (obj instanceof Number || obj instanceof Boolean) {
      return obj.toString();
    }
    if (obj instanceof Map) {
      Map<String, Object> map = (Map<String, Object>) obj;
      StringBuilder sb = new StringBuilder("{");
      boolean first = true;
      for (Map.Entry<String, Object> entry : map.entrySet()) {
        if (!first)
          sb.append(",");
        sb.append("\"").append(escapeJson(entry.getKey())).append("\":");
        sb.append(toJson(entry.getValue()));
        first = false;
      }
      sb.append("}");
      return sb.toString();
    }
    if (obj instanceof List) {
      List<Object> list = (List<Object>) obj;
      StringBuilder sb = new StringBuilder("[");
      boolean first = true;
      for (Object item : list) {
        if (!first)
          sb.append(",");
        sb.append(toJson(item));
        first = false;
      }
      sb.append("]");
      return sb.toString();
    }
    return "null";
  }

  private static String escapeJson(String s) {
    return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r")
        .replace("\t", "\\t");
  }

  private static Map<String, Object> parseJson(String json) {
    json = json.trim();
    if (json.isEmpty() || json.equals("{}")) {
      return new HashMap<>();
    }
    return parseJsonObject(json, new int[] { 0 });
  }

  private static Map<String, Object> parseJsonObject(String json, int[] pos) {
    Map<String, Object> map = new LinkedHashMap<>();
    skipWhitespace(json, pos);
    if (pos[0] >= json.length() || json.charAt(pos[0]) != '{') {
      return map;
    }
    pos[0]++;
    skipWhitespace(json, pos);

    while (pos[0] < json.length() && json.charAt(pos[0]) != '}') {
      skipWhitespace(json, pos);
      String key = parseJsonString(json, pos);
      skipWhitespace(json, pos);
      if (pos[0] < json.length() && json.charAt(pos[0]) == ':') {
        pos[0]++;
      }
      skipWhitespace(json, pos);
      Object value = parseJsonValue(json, pos);
      map.put(key, value);
      skipWhitespace(json, pos);
      if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
        pos[0]++;
      }
      skipWhitespace(json, pos);
    }
    if (pos[0] < json.length() && json.charAt(pos[0]) == '}') {
      pos[0]++;
    }
    return map;
  }

  private static List<Object> parseJsonArray(String json, int[] pos) {
    List<Object> list = new java.util.ArrayList<>();
    skipWhitespace(json, pos);
    if (pos[0] >= json.length() || json.charAt(pos[0]) != '[') {
      return list;
    }
    pos[0]++;
    skipWhitespace(json, pos);

    while (pos[0] < json.length() && json.charAt(pos[0]) != ']') {
      Object value = parseJsonValue(json, pos);
      list.add(value);
      skipWhitespace(json, pos);
      if (pos[0] < json.length() && json.charAt(pos[0]) == ',') {
        pos[0]++;
      }
      skipWhitespace(json, pos);
    }
    if (pos[0] < json.length() && json.charAt(pos[0]) == ']') {
      pos[0]++;
    }
    return list;
  }

  private static Object parseJsonValue(String json, int[] pos) {
    skipWhitespace(json, pos);
    if (pos[0] >= json.length()) {
      return null;
    }
    char c = json.charAt(pos[0]);
    if (c == '{') {
      return parseJsonObject(json, pos);
    }
    if (c == '[') {
      return parseJsonArray(json, pos);
    }
    if (c == '"') {
      return parseJsonString(json, pos);
    }
    if (c == 't' || c == 'f') {
      if (json.startsWith("true", pos[0])) {
        pos[0] += 4;
        return true;
      }
      if (json.startsWith("false", pos[0])) {
        pos[0] += 5;
        return false;
      }
    }
    if (c == 'n') {
      if (json.startsWith("null", pos[0])) {
        pos[0] += 4;
        return null;
      }
    }
    if (c == '-' || Character.isDigit(c)) {
      int start = pos[0];
      if (c == '-')
        pos[0]++;
      while (pos[0] < json.length() && Character.isDigit(json.charAt(pos[0]))) {
        pos[0]++;
      }
      if (pos[0] < json.length() && json.charAt(pos[0]) == '.') {
        pos[0]++;
        while (pos[0] < json.length() && Character.isDigit(json.charAt(pos[0]))) {
          pos[0]++;
        }
      }
      if (pos[0] < json.length() && (json.charAt(pos[0]) == 'e' || json.charAt(pos[0]) == 'E')) {
        pos[0]++;
        if (pos[0] < json.length() && (json.charAt(pos[0]) == '+' || json.charAt(pos[0]) == '-')) {
          pos[0]++;
        }
        while (pos[0] < json.length() && Character.isDigit(json.charAt(pos[0]))) {
          pos[0]++;
        }
      }
      String numStr = json.substring(start, pos[0]);
      try {
        if (numStr.contains(".") || numStr.contains("e") || numStr.contains("E")) {
          return Double.parseDouble(numStr);
        } else {
          return Long.parseLong(numStr);
        }
      } catch (NumberFormatException e) {
        return numStr;
      }
    }
    return null;
  }

  private static String parseJsonString(String json, int[] pos) {
    skipWhitespace(json, pos);
    if (pos[0] >= json.length() || json.charAt(pos[0]) != '"') {
      return "";
    }
    pos[0]++;
    StringBuilder sb = new StringBuilder();
    while (pos[0] < json.length() && json.charAt(pos[0]) != '"') {
      char c = json.charAt(pos[0]);
      if (c == '\\' && pos[0] + 1 < json.length()) {
        pos[0]++;
        char escaped = json.charAt(pos[0]);
        switch (escaped) {
          case '"':
            sb.append('"');
            break;
          case '\\':
            sb.append('\\');
            break;
          case '/':
            sb.append('/');
            break;
          case 'b':
            sb.append('\b');
            break;
          case 'f':
            sb.append('\f');
            break;
          case 'n':
            sb.append('\n');
            break;
          case 'r':
            sb.append('\r');
            break;
          case 't':
            sb.append('\t');
            break;
          case 'u':
            if (pos[0] + 4 < json.length()) {
              String hex = json.substring(pos[0] + 1, pos[0] + 5);
              sb.append((char) Integer.parseInt(hex, 16));
              pos[0] += 4;
            }
            break;
          default:
            sb.append(escaped);
        }
      } else {
        sb.append(c);
      }
      pos[0]++;
    }
    if (pos[0] < json.length() && json.charAt(pos[0]) == '"') {
      pos[0]++;
    }
    return sb.toString();
  }

  private static void skipWhitespace(String json, int[] pos) {
    while (pos[0] < json.length() && Character.isWhitespace(json.charAt(pos[0]))) {
      pos[0]++;
    }
  }
}
// This code uses the Extend REST API directly because Extend has no official Go SDK yet.
// It deploys the "Receipt" pipeline to your Extend account.
//
// Usage:
//   export EXTEND_API_KEY=sk_...   (from https://dashboard.extend.ai → API Keys)
//   go run provision.go
//
// Generated by doc1 (template: receipt-parser).

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
)

const (
	API     = "https://api.extend.ai"
	VERSION = "2026-02-09"
)

type State struct {
	WorkflowID string `json:"workflowId,omitempty"`
}

type WorkflowStep struct {
	Name   string        `json:"name"`
	Type   string        `json:"type"`
	Next   []interface{} `json:"next,omitempty"`
	Config interface{}   `json:"config,omitempty"`
}

type Workflow struct {
	Name  string         `json:"name"`
	Steps []WorkflowStep `json:"steps"`
}

type WorkflowResponse struct {
	ID       string `json:"id,omitempty"`
	Workflow struct {
		ID string `json:"id,omitempty"`
	} `json:"workflow,omitempty"`
}

type WorkflowListResponse struct {
	Data  []WorkflowItem `json:"data,omitempty"`
	Items []WorkflowItem `json:"items,omitempty"`
}

type WorkflowItem struct {
	Name string `json:"name,omitempty"`
	ID   string `json:"id,omitempty"`
}

var (
	apiKey   string
	stateDir string
	stateFile string
	state    State
)

func init() {
	apiKey = os.Getenv("EXTEND_API_KEY")
	if apiKey == "" {
		fmt.Fprintf(os.Stderr, "Set EXTEND_API_KEY first.\n")
		os.Exit(1)
	}

	stateDir = filepath.Join(".", ".extend")
	stateFile = filepath.Join(stateDir, "receipt-parser.json")

	// Load existing state if it exists
	if data, err := os.ReadFile(stateFile); err == nil {
		json.Unmarshal(data, &state)
	}
}

func saveState() error {
	if err := os.MkdirAll(stateDir, 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(stateFile, data, 0644)
}

func apiCall(method, pathName string, body interface{}) (map[string]interface{}, error) {
	var reqBody io.Reader
	if body != nil {
		bodyBytes, err := json.Marshal(body)
		if err != nil {
			return nil, err
		}
		reqBody = bytes.NewReader(bodyBytes)
	}

	req, err := http.NewRequest(method, API+pathName, reqBody)
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
	req.Header.Set("x-extend-api-version", VERSION)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var data map[string]interface{}
	json.Unmarshal(respBody, &data)

	if resp.StatusCode >= 400 {
		respStr := string(respBody)
		if len(respStr) > 300 {
			respStr = respStr[:300]
		}
		return nil, fmt.Errorf("%s %s failed (%d): %s", method, pathName, resp.StatusCode, respStr)
	}

	return data, nil
}

func main() {
	workflow := Workflow{
		Name: "Receipt Processing Pipeline",
		Steps: []WorkflowStep{
			{
				Name: "startTrigger1",
				Type: "TRIGGER",
				Next: []interface{}{
					map[string]string{"step": "parse1"},
				},
			},
			{
				Name: "parse1",
				Type: "PARSE",
				Config: map[string]interface{}{
					"parseConfig": map[string]interface{}{
						"blockOptions": map[string]interface{}{
							"text": map[string]interface{}{
								"agentic": map[string]bool{
									"enabled": true,
								},
								"signatureDetectionEnabled": true,
							},
							"tables": map[string]interface{}{
								"agentic": map[string]bool{
									"enabled": true,
								},
								"tableHeaderContinuationEnabled": true,
							},
							"figures": map[string]interface{}{
								"enabled": true,
							},
						},
						"chunkingStrategy": map[string]interface{}{
							"type":    "page",
							"options": map[string]interface{}{},
						},
					},
				},
			},
		},
	}

	fmt.Printf("Deploying \"%s\"…\n", workflow.Name)

	if state.WorkflowID != "" {
		fmt.Printf("✓ workflow already provisioned (%s) — updating steps\n", state.WorkflowID)
		_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", state.WorkflowID), map[string]interface{}{"steps": workflow.Steps})
		if err != nil {
			fmt.Fprintf(os.Stderr, "%v\n", err)
			os.Exit(1)
		}
	} else {
		// Try to find existing workflow with same name
		query := url.QueryEscape(workflow.Name)
		listResp, err := apiCall("GET", fmt.Sprintf("/workflows?name=%s", query), nil)
		if err == nil {
			var items []WorkflowItem
			if data, ok := listResp["data"].([]interface{}); ok {
				for _, item := range data {
					if itemMap, ok := item.(map[string]interface{}); ok {
						var wi WorkflowItem
						itemBytes, _ := json.Marshal(itemMap)
						json.Unmarshal(itemBytes, &wi)
						items = append(items, wi)
					}
				}
			} else if data, ok := listResp["items"].([]interface{}); ok {
				for _, item := range data {
					if itemMap, ok := item.(map[string]interface{}); ok {
						var wi WorkflowItem
						itemBytes, _ := json.Marshal(itemMap)
						json.Unmarshal(itemBytes, &wi)
						items = append(items, wi)
					}
				}
			}

			for _, item := range items {
				if item.Name == workflow.Name && item.ID != "" {
					state.WorkflowID = item.ID
					saveState()
					fmt.Printf("✓ workflow \"%s\" found in your account (%s) — updating steps\n", workflow.Name, item.ID)
					_, err := apiCall("POST", fmt.Sprintf("/workflows/%s", item.ID), map[string]interface{}{"steps": workflow.Steps})
					if err != nil {
						fmt.Fprintf(os.Stderr, "%v\n", err)
						os.Exit(1)
					}
					break
				}
			}
		}

		if state.WorkflowID == "" {
			created, err := apiCall("POST", "/workflows", workflow)
			if err != nil {
				fmt.Fprintf(os.Stderr, "%v\n", err)
				os.Exit(1)
			}

			wfID := ""
			if id, ok := created["id"].(string); ok {
				wfID = id
			} else if wfObj, ok := created["workflow"].(map[string]interface{}); ok {
				if id, ok := wfObj["id"].(string); ok {
					wfID = id
				}
			}

			if wfID == "" {
				fmt.Fprintf(os.Stderr, "Could not read created workflow id from response\n")
				os.Exit(1)
			}

			state.WorkflowID = wfID
			saveState()
			fmt.Printf("+ created workflow (%s)\n", wfID)
		}
	}

	// Deploy the current draft as a new version (best-effort)
	apiCall("POST", fmt.Sprintf("/workflows/%s/versions", state.WorkflowID), map[string]interface{}{})

	fmt.Printf("\nDone. Run documents through it with:\n")
	fmt.Printf("  POST %s/workflow_runs  { workflow: { id: \"%s\" }, file: { url: \"https://…\" } }\n", API, state.WorkflowID)
	fmt.Printf("Or open the workflow in the Extend dashboard to review and deploy it.\n")
}

Frequently Asked Questions (FAQ)

Use sync mode (`client.extract()`) for receipts under ~5 pages or real-time checkout flows where latency matters; use async (`client.extractRuns.createAndPoll()`) for batch processing high-volume receipts or when OCR quality is critical on poor-quality thermal scans.
Yes. Your Parse step produces good markdown with tables, but the data is unstructured. Add an Extract step with a schema for receipt fields like: items (array of {name, price}), subtotal, total, payment_method, card_last_four, clubcard_number, and transaction_date. Use `extraction_quality` since receipts have consistent, well-defined layouts.
Tags
RetailReceiptTransactionPOSInventory
About this template

This template processes retail receipts to capture transaction details including store information, itemized products with prices, subtotals, taxes, and payment method confirmations. It handles variable-length item lists and payment authorization codes commonly found in point-of-sale receipts.

Document formats
  • PDF
  • Images & Scans
Requirements
  • Scanned documents
  • Long tables

Relevant templates for Financial & Banking

  1. 01
    Driver's License ExtractorParse → Extract
    Extracts personal identification and licensing data from driver license documents.
    PDFImages & Scanswww.extend.ai/templates/driver-license-template
  2. 02
    Receipt ExtractorParse → Extract
    Extracts itemized sales, pricing, GST tax, and payment details from retail receipts.
    PDFImages & Scanswww.extend.ai/templates/receipt-parse-extract
  3. 03
    Bank Statement ExtractorParse → Extract
    Extracts account summaries, balances, deposits, NSF flags, and transaction details from bank statements.
    PDFImages & Scanswww.extend.ai/templates/bank-statement
  4. 04
    Vendor Invoice ExtractorParse → Extract
    Extracts charges, billing details, line items, totals, and due dates vendor invoices.
    PDFImages & Scanswww.extend.ai/templates/vendor-invoice
  5. 05
    Pay Stub ExtractorParse → Extract
    Extracts employee earnings, deductions, taxes, and net pay from pay stubs.
    PDFwww.extend.ai/templates/pay-stub
  6. 06
    Check ExtractorParse → Extract
    Extracts check details including payee, amount, date, and bank routing information.
    PDFImages & Scanswww.extend.ai/templates/check
  7. 07
    Wire Transfer Instructions ExtractorParse → Extract
    Extracts wire transfer procedures, contact info, and operational hours from banking guides.
    PDFWord / DOCXwww.extend.ai/templates/wire-transfer-instructions
  8. 08
    Onboarding Package ExtractorParse → Extract
    Extracts account summaries, transaction details, and balances from bank statements.
    PDFImages & Scanswww.extend.ai/templates/personal-bank-statement