Commerce and Order Management

Eventuall integrates with Shopify to sell event tickets and automatically grant room access when customers complete purchases. The system also supports importing orders from CSV files for retailers that don't have direct webhook integrations. This document covers the Shopify integration, the webhook pipeline, the order import system, and the SKU mapping layer that ties external commerce to internal access control.

Shopify Integration Overview

The Shopify integration is a server-side system that lives entirely in the webapp. It uses the Shopify Admin GraphQL API (version 2025-04) through the @shopify/admin-api-client package. There's no client-side Shopify code — all Shopify communication happens in tRPC procedures and webhook handlers.

The integration serves two purposes:

  1. Outbound: Creating products in Shopify that represent event tickets, so organizers can sell tickets through their Shopify store
  2. Inbound: Receiving webhook notifications when orders are paid, and automatically granting room access to customers
graph LR
    subgraph "Eventuall"
        Admin["Admin Dashboard"]
        WebhookHandler["Webhook Handler"]
        ShopifyService["ShopifyService"]
        DB["D1 Database"]
        EmailService["EmailService"]
    end

    subgraph "Shopify"
        Store["Shopify Store"]
        AdminAPI["Admin GraphQL API"]
        Webhooks["Webhook System"]
    end

    Admin -->|Create event product| ShopifyService
    ShopifyService -->|GraphQL mutations| AdminAPI
    AdminAPI -->|Product created| Store
    Store -->|Customer pays| Webhooks
    Webhooks -->|orders/paid| WebhookHandler
    WebhookHandler -->|Process order| ShopifyService
    ShopifyService -->|Grant access| DB
    ShopifyService -->|Send reminder| EmailService

API Client Setup

The Shopify client is initialized in apps/webapp/src/server/integrations/shopify.ts. It creates an Admin API client configured with the store domain, API version, and access token from environment variables:

import { createAdminApiClient } from "@shopify/admin-api-client";

export async function createShopifyClient() {
  const client = createAdminApiClient({
    storeDomain: process.env.SHOPIFY_SHOP_NAME,
    apiVersion: "2025-04",
    accessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN,
  });
  return { client };
}

The client is used exclusively in ShopifyService — no other part of the codebase talks to Shopify directly.

Environment Variables

Variable Purpose
SHOPIFY_SHOP_NAME Store domain (e.g., 9d1e1b-e5.myshopify.com)
SHOPIFY_ADMIN_ACCESS_TOKEN App password for Admin API access (prefixed shpat_)
SHOPIFY_STOREFRONT_ACCESS_TOKEN For storefront queries
SHOPIFY_API_KEY App API key
SHOPIFY_API_SECRET App API secret
SHOPIFY_VENDOR_IDENTIFIER Vendor name set on created products

Creating Event Products in Shopify

When an organizer wants to sell tickets through Shopify, the system creates a product in their Shopify store. This is handled by ShopifyService.createEventInShopify().

The process:

  1. Generate a unique SKU via SkuService.createUniqueSku() — this becomes the identifier that links a Shopify line item back to an Eventuall room
  2. Create a room access ticket in the database that maps the SKU to a specific room and role (defaults to "Participant" role with a "General Admission" label)
  3. Create the Shopify product using the productSet GraphQL mutation with:
    • Title matching the event name
    • Product type "Event Ticket"
    • A single variant with the generated SKU
    • Custom metafields storing eventId, roomId, eventUrl, and deepLinkUrl
    • Assigned to "Events" and "Home page" collections
  4. Publish the product to sales channels: Online Store, Google & YouTube, Facebook & Instagram, and Shop Button
sequenceDiagram
    participant Admin as Admin Dashboard
    participant Service as ShopifyService
    participant SkuService as SkuService
    participant DB as D1 Database
    participant Shopify as Shopify Admin API

    Admin->>Service: createEventInShopify(event, room)
    Service->>SkuService: createUniqueSku()
    SkuService-->>Service: SKU ID
    Service->>SkuService: createRoomAccessTicket(roomId, roleId, skuId)
    SkuService->>DB: Insert roomAccessTicket + skuProductLink
    Service->>Shopify: getCollectionsQuery
    Shopify-->>Service: Collection IDs
    Service->>Shopify: productSetMutation (title, SKU, metafields)
    Shopify-->>Service: Product ID
    Service->>Shopify: getPublicationsQuery
    Shopify-->>Service: Publication IDs
    Service->>Shopify: publishablePublishMutation
    Shopify-->>Service: Published

The product is created in DRAFT status with inventoryPolicy: "CONTINUE" (meaning it can be sold even when out of stock) and requiresShipping: false (since it's a digital ticket).

Architect's Note: The metafields on the Shopify product (custom.eventId, custom.roomId, custom.eventUrl, custom.deepLinkUrl) serve as the bridge between the two systems. When querying Shopify for products related to an event, the system searches by metafields.custom.eventId to find matches. The deepLinkUrl metafield gives Shopify theme developers a URL to link customers directly to their event room after purchase.

Shopify Webhook Pipeline

The webhook handler is the core of the automated access-granting system. When a customer pays for an order in Shopify, Shopify sends a webhook to Eventuall, which processes the order and grants room access.

Webhook Endpoint

The handler lives at apps/webapp/src/app/api/webhook/shopify/[...event]/route.ts. The catch-all route segment [...event] captures the webhook event type (e.g., orders/paid).

POST /api/webhook/shopify/orders/paid

The handler:

  1. Parses the incoming JSON body
  2. Joins the route segments to determine the event type
  3. For orders/paid, validates the payload against webhookOrderSchema (a Zod schema)
  4. Delegates to ShopifyService.orderPaid()
  5. Returns 200 with "Webhook received"

Currently, orders/paid is the only webhook event handled. The catch-all route structure makes it straightforward to add more events in the future.

Order Processing (`orderPaid`)

When a paid order comes in, the processing flow walks through each line item's SKU and resolves it to internal access grants:

sequenceDiagram
    participant Shopify as Shopify Webhook
    participant Handler as Webhook Route
    participant Service as ShopifyService
    participant DB as D1 Database
    participant Users as UsersService
    participant Rooms as RoomService
    participant Email as EmailService

    Shopify->>Handler: POST /api/webhook/shopify/orders/paid
    Handler->>Handler: Validate with webhookOrderSchema
    Handler->>Service: orderPaid(order)

    loop For each line item SKU
        Service->>DB: Find skuProductLinks by SKU
        Service->>DB: Find productType for link
        alt productType is roomAccessTickets
            Service->>DB: Find roomAccessTicket
            Service->>DB: Find room (for eventId)
            Service->>Users: getUserByEmail(customer.email)
            alt User doesn't exist
                Service->>Users: createUserProfile(customer data)
            end
            Service->>Rooms: grantRoomAccessTicketByEmail(ticketId, email)
        end
    end

    Service->>Email: sendEventRemindersGroupedByEvent()
    Handler-->>Shopify: 200 "Webhook received"

The key steps in ShopifyService.orderPaid():

  1. Extract SKUs from order.line_items[].sku
  2. Look up each SKU in the skuProductLinks table to find what product it maps to
  3. Resolve the product type — currently only roomAccessTickets is supported, but the switch statement is designed to support additional product types
  4. Find the room access ticket and its associated room
  5. Find or create the user — if no user exists with the customer's email, a new profile is created from the Shopify customer data (name, email)
  6. Grant room access via RoomService.grantRoomAccessTicketByEmail()
  7. Send event reminder emails grouped by event to avoid duplicate emails when an order contains multiple line items for the same event

Pitfall: The webhook handler does not currently validate the Shopify HMAC signature. The payload is validated structurally via the Zod schema, but there's no cryptographic verification that the request actually came from Shopify. The webhook URL is unguessable but not authenticated. This is a known gap.

Architect's Note: Email sending is wrapped in a try-catch that logs failures but doesn't fail the order processing. This is intentional — access granting is the critical operation, and email delivery failures shouldn't prevent a customer from getting access to their event.

Webhook Schema Validation

The webhookOrderSchema in apps/webapp/src/server/schemas/shopify.schema.ts is a comprehensive Zod schema that validates the full Shopify order webhook payload. It covers:

  • Order metadata (id, name, email, status, timestamps)
  • Customer information
  • Line items with SKU, quantity, and pricing
  • Billing and shipping addresses
  • Discount codes and applications
  • Tax lines
  • Fulfillment information
  • Refund data
  • Transaction records

Every field is marked as .nullable().optional() because Shopify's webhook payloads vary depending on the order type and the store's configuration. The schema is permissive on purpose — it ensures the data is structurally valid without rejecting orders that happen to be missing optional fields.

Order Import from CSV

Not all ticket sales come through Shopify's webhook pipeline. Organizers may sell tickets through other retailers (Eventbrite, Stripe, manual sales) or need to backfill historical orders. The CSV import system handles these cases.

How CSV Import Works

The import process is triggered from the admin dashboard's Orders tab via the Import Orders modal (ImportOrdersModal.tsx). Users upload a CSV file, select a retailer, and the system processes it through the importOrdersFromCsv tRPC procedure.

sequenceDiagram
    participant Admin as Admin Dashboard
    participant Modal as ImportOrdersModal
    participant tRPC as order.importOrdersFromCsv
    participant DB as D1 Database
    participant Rooms as RoomService
    participant Users as UsersService
    participant Email as EmailService

    Admin->>Modal: Upload CSV file
    Modal->>tRPC: csvContent, eventId, retailerId
    tRPC->>tRPC: Parse CSV (csv-parse/sync)
    tRPC->>DB: Load SKU mappings for event + retailer
    tRPC->>DB: Load event room access ticket SKUs

    loop For each order group (by order ID or email)
        tRPC->>DB: Insert order record
        tRPC->>DB: Insert line items (with SKU resolution)
        alt Order status is "paid"
            loop For each mapped line item
                tRPC->>Users: Find or create user profile
                tRPC->>Rooms: grantRoomAccessTicketByEmail()
            end
        end
    end

    tRPC->>Email: sendEventRemindersGroupedByEvent()
    tRPC-->>Modal: Import results (success/failed/unmapped)

CSV Format

The import supports both Shopify's native export format and a generic CSV format. Column matching is case-insensitive and handles multiple naming conventions:

Data Accepted Column Names
Email (required) customer_email, email, customeremail
Order ID order_id, orderid, order_number, ordernumber, name
Status status, order_status, financial_status
Order date order_date, orderdate, date, created_at
Customer name customer_name, customername, billing_name, shipping_name
First name first_name, firstname, given_name
Last name last_name, lastname, family_name
SKU sku, product_sku, item_sku, lineitem_sku
Item name item_name, itemname, product_name, lineitem_name
Quantity quantity, qty, lineitem_quantity
Unit price unit_price, unitprice, price, lineitem_price
Total price total_price, totalprice, total

Valid order statuses: pending, paid, partially_fulfilled, fulfilled, cancelled, refunded.

Order Grouping

CSV rows are grouped into orders. If a row has an order_id, all rows with the same order ID become line items under a single order. If there's no order ID, rows are grouped by email address. This means a CSV from Shopify (which includes order numbers) will correctly group multi-item orders, while a simpler CSV with just email and SKU will create one order per email.

Access Granting on Import

When a CSV order has a status of paid, the import process automatically grants room access for each mapped line item — the same logic as the webhook pipeline. Users who don't yet have accounts get a pending profile created from their CSV data. Event reminder emails are sent after the import completes, deduplicated by event per user.

The import returns a results object with counts of successful imports, failures, errors, and a list of unmapped SKUs that need attention.

SKU Mapping System

The SKU mapping system is the bridge between external commerce platforms and Eventuall's internal access control. It solves a fundamental problem: external retailers use their own SKU identifiers, but Eventuall needs to map those to specific room access tickets.

How SKU Resolution Works

There are two ways a SKU gets resolved to internal access:

  1. Direct match — The SKU in the order exactly matches an internal SKU created by SkuService.createUniqueSku() and linked via skuProductLinks. This is the path for orders coming through the Shopify webhook, where the SKU was generated by Eventuall.

  2. External mapping — The external SKU is manually mapped to one or more internal SKUs through the externalSkuMappings table. This is the path for CSV imports and other retailers where the external SKU doesn't match an internal one.

graph TB
    subgraph "External Commerce"
        ShopifyOrder["Shopify Order
SKU: EVT-ABC123"] CSVOrder["CSV Import
SKU: RETAILER-XYZ"] end subgraph "SKU Resolution" DirectMatch["Direct Match
skuProductLinks"] ExternalMapping["External Mapping
externalSkuMappings"] MappingSkus["Mapping SKUs
externalSkuMappingSkus"] end subgraph "Internal Access" SkuProductLink["skuProductLinks"] ProductType["productTypes"] RoomAccessTicket["roomAccessTickets"] AccessGrant["Room Access Grant"] end ShopifyOrder -->|SKU matches internal| DirectMatch CSVOrder -->|SKU needs mapping| ExternalMapping ExternalMapping --> MappingSkus MappingSkus --> SkuProductLink DirectMatch --> SkuProductLink SkuProductLink --> ProductType ProductType -->|tableName: roomAccessTickets| RoomAccessTicket RoomAccessTicket --> AccessGrant

Database Tables

Table Purpose
retailers Stores retailer/platform info (Shopify, Eventbrite, etc.)
externalSkuMappings Maps an external SKU string to an event + retailer combination
externalSkuMappingSkus Junction table linking a mapping to one or more internal SKUs
orders Order records with customer info, status, and retailer reference
orderLineItems Individual line items with external SKU, resolved internal SKU, and mapping reference
skuProductLinks Links an internal SKU to a product type and product ID
productTypes Defines product categories (currently only roomAccessTickets)
roomAccessTickets Defines a ticket granting a specific role in a specific room

Managing SKU Mappings

SKU mappings are managed per event and per retailer through the admin dashboard. When an organizer creates a mapping:

  1. The external SKU string is recorded (case-insensitive matching)
  2. One or more internal SKUs are linked via the junction table
  3. Any existing unmapped order line items matching that external SKU are retroactively updated
  4. For paid orders with newly mapped SKUs, room access is granted immediately

This retroactive mapping is important — it means organizers can import orders first, then set up mappings afterward. The system will catch up and grant access for all previously imported paid orders.

Architect's Note: The multi-retailer, multi-SKU mapping design is intentional even though most events currently use a single Shopify store. Event organizers sometimes sell tickets through multiple channels (their own website, third-party retailers, box office sales), each with different SKU schemes. The mapping system accommodates this without requiring every retailer to use the same SKUs.

Viewing Orders in the Dashboard

The admin dashboard provides two views for orders:

Shopify Orders View

The ShopifyOrdersSection component fetches orders directly from the Shopify Admin API in real-time. It calls getShopifyOrdersForEvent, which:

  1. Finds all room access ticket SKUs for the event
  2. Queries Shopify's GraphQL API for orders containing those SKUs
  3. Returns comprehensive order data including customer details, payments, fulfillments, refunds, and timeline events

This view shows a summary table with order status badges, and a detailed order viewer with tabs for Overview, Items, Payments, Fulfillment, Timeline, and Customer information. Each order links directly to the Shopify admin for the full order management experience.

Imported Orders View

The OrdersTable component shows orders stored in the local database — those imported via CSV or created manually. This view supports search, filtering by retailer and status, and pagination. Unmapped SKUs are highlighted so organizers know which mappings still need to be configured.

Key Files Reference

File Purpose
server/integrations/shopify.ts Shopify Admin API client setup
server/services/shopify.service.ts Core Shopify business logic
server/schemas/shopify.schema.ts Webhook payload Zod validation
server/schemas/order.schema.ts Order CRUD and import schemas
server/api/routers/order/order.router.ts All order-related tRPC procedures
shopify/graphql-queries.ts Shopify GraphQL queries and mutations
app/api/webhook/shopify/[...event]/route.ts Webhook endpoint
app/dashboard/.../\@orders/ShopifyOrdersSection.tsx Shopify orders UI
app/dashboard/.../\@orders/ImportOrdersModal.tsx CSV import UI