npm package: @maxime-ns/back-market
Description: A back market component for Convex.
Latest version: 0.5.0

# @maxime-ns/back-market

A Convex component for integrating
[Back Market's Marketplace Orders API](https://api.backmarket.dev/) into your
Convex application.

[![npm version](https://badge.fury.io/js/@maxime-ns%2Fback-market.svg)](https://badge.fury.io/js/@maxime-ns%2Fback-market)

## Features

- **Connection Management** - Connect, disconnect, and manage Back Market API
  credentials per organization
- **Order Syncing** - Automatic order sync every 5 minutes via cron job
- **Orderlines** - Query and update individual orderlines with IMEI support
- **Listings Management** - Sync, update, and bulk create/update listings via
  CSV
- **BackShip Integration** - Full support for deliveries and returns with label
  storage
- **Real-time Data** - Query synced data from your Convex database in real-time
- **Multi-tenant** - Support multiple organizations with separate API keys
- **Environment Support** - Switch between sandbox and production environments

## Quick Start

### 1. Install the Component

```bash
npm install @maxime-ns/back-market
```

### 2. Add to Your Convex App

Create or update `convex/convex.config.ts`:

```ts
import { defineApp } from "convex/server";
import backMarket from "@maxime-ns/back-market/convex.config.js";

const app = defineApp();
app.use(backMarket);

export default app;
```

### 3. Get Your Back Market API Key

1. Go to [Back Market Developer Portal](https://api.backmarket.dev/)
2. Create an application or use an existing one
3. Copy your API key (for sandbox testing, use the sandbox API key)

### 4. Understand Multi-Tenancy

This component is designed to support **multiple organizations**, each with
their own Back Market API key. The `organizationId` parameter is a string that
you provide from your host application.

#### Single Organization (Simple Setup)

If your app only needs one Back Market connection, you can use any constant
string as the organization ID:

```ts
// Use a constant for single-tenant apps
const ORGANIZATION_ID = "default";

// Or use the authenticated user's ID
const organizationId = identity.subject;
```

#### Multiple Organizations (Multi-Tenant Setup)

For multi-tenant applications where each organization has their own Back Market
account, pass the organization ID from your auth system. The component will:

- Store separate API keys per organization
- Sync orders independently for each organization
- Keep data isolated between organizations

### 5. Use the Component

Create `convex/backmarket.ts`:

```ts
import { action, mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { BackMarket } from "@maxime-ns/back-market";
import { v } from "convex/values";

const backMarket = new BackMarket(components.backMarket);

// Connect an organization to Back Market
export const connect = mutation({
  args: {
    organizationId: v.string(),
    apiKey: v.string(),
  },
  handler: async (ctx, args) => {
    return await backMarket.upsertConnection(ctx, {
      organizationId: args.organizationId,
      apiKey: args.apiKey,
    });
  },
});

// Get connection status
export const getConnectionStatus = query({
  args: { organizationId: v.string() },
  handler: async (ctx, args) => {
    return await backMarket.getConnectionStatus(ctx, {
      organizationId: args.organizationId,
    });
  },
});

// Get orders from local database
export const getOrders = query({
  args: {
    organizationId: v.string(),
    state: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    return await backMarket.getOrders(ctx, {
      organizationId: args.organizationId,
      state: args.state,
    });
  },
});

// Manually sync orders from Back Market API
export const syncOrders = action({
  args: { organizationId: v.string() },
  handler: async (ctx, args) => {
    return await backMarket.syncOrders(ctx, {
      organizationId: args.organizationId,
      environment: "production",
    });
  },
});
```

## Order States

Back Market uses numeric states for orders:

| State | Description                                    |
| ----- | ---------------------------------------------- |
| `0`   | New Order - Payment validation pending         |
| `1`   | Paid - Payment validated, process orderlines   |
| `3`   | Shipping pending - Ready to ship               |
| `8`   | Not paid - Payment failed                      |
| `9`   | Processed - Package shipped                    |
| `10`  | Pending - Customer ordered but hasn't paid yet |

## Orderline States

Individual orderlines within an order have their own states:

| State | Description                    |
| ----- | ------------------------------ |
| `0`   | New                            |
| `1`   | Paid (Pending validation)      |
| `2`   | Validated (Accepted by seller) |
| `3`   | Shipped                        |
| `4`   | Completed (Delivered)          |
| `5`   | Cancellation requested         |
| `6`   | Cancelled/Refunded             |
| `7`   | Rejected by buyer              |
| `8`   | Not paid                       |
| `9`   | Waiting for action             |

## Listing Publication States

| State | Description            |
| ----- | ---------------------- |
| `0`   | Not online             |
| `1`   | Online                 |
| `2`   | Waiting for validation |
| `3`   | Rejected               |
| `4`   | Pending                |

## API Reference

### Using the BackMarket Client

The component provides a `BackMarket` client class for a more ergonomic API:

```ts
import { BackMarket } from "@maxime-ns/back-market";
import { components } from "./_generated/api";

const backMarket = new BackMarket(components.backMarket);
```

### Connection Methods

| Method                  | Context  | Description                                |
| ----------------------- | -------- | ------------------------------------------ |
| `getConnectionStatus()` | Query    | Get connection status (no secrets exposed) |
| `upsertConnection()`    | Mutation | Create or update a connection              |
| `disconnect()`          | Mutation | Disable a connection                       |
| `reconnect()`           | Mutation | Re-enable a previously disabled connection |
| `removeConnection()`    | Mutation | Permanently delete a connection            |

### Order Methods

| Method           | Context | Description                            |
| ---------------- | ------- | -------------------------------------- |
| `getOrders()`    | Query   | Get orders from local database         |
| `getOrderById()` | Query   | Get a single order by its ID           |
| `syncOrders()`   | Action  | Manually sync orders to local database |
| `updateOrder()`  | Action  | Update order state, tracking, etc.     |

#### Shipping an Order

```ts
// Ship an order with tracking info
const result = await backMarket.updateOrder(ctx, {
  organizationId: "org_123",
  orderId: 5978,
  body: {
    new_state: 3, // Shipped
    tracking_number: "1Z999AA10123456784",
    tracking_url: "https://tracking.example.com/1Z999AA10123456784",
    shipper: "UPS",
  },
  environment: "production",
});
```

#### Cancelling/Refunding an Order

```ts
const result = await backMarket.updateOrder(ctx, {
  organizationId: "org_123",
  orderId: 5978,
  body: {
    new_state: 6, // Cancelled/Refunded
    return_reason: 0, // Stock mistake
    return_message: "Item out of stock",
  },
  environment: "production",
});
```

### Orderline Methods

| Method                     | Context | Description                   |
| -------------------------- | ------- | ----------------------------- |
| `getOrderlines()`          | Query   | Get orderlines for an order   |
| `getOrderlineById()`       | Query   | Get a single orderline by ID  |
| `getOrderlinesByListing()` | Query   | Get orderlines by SKU/listing |
| `getOrderlinesByState()`   | Query   | Get orderlines by state       |
| `updateOrderline()`        | Action  | Update orderline (e.g., IMEI) |

#### Updating IMEI on an Orderline

```ts
// Update IMEI for a smartphone orderline
const result = await backMarket.updateOrderline(ctx, {
  organizationId: "org_123",
  orderlineId: 12345,
  orderId: 5978,
  body: {
    imei: "153124587625348",
  },
  environment: "production",
});

if (result.success) {
  console.log(`IMEI updated to: ${result.imei}`);
}
```

### Invoice Methods

| Method                 | Context | Description                        |
| ---------------------- | ------- | ---------------------------------- |
| `uploadOrderInvoice()` | Action  | Upload custom invoice PDF to order |

#### Uploading an Invoice

```ts
// Get a signed URL for the invoice from your app's storage
const invoiceUrl = await ctx.storage.getUrl(invoiceStorageId);

// Upload to Back Market
const result = await backMarket.uploadOrderInvoice(ctx, {
  organizationId: "org_123",
  orderId: 5978,
  invoiceUrl,
  filename: "invoice-5978.pdf",
  environment: "production",
});
```

### Listing Methods

| Method                            | Context | Description                                |
| --------------------------------- | ------- | ------------------------------------------ |
| `getListings()`                   | Query   | Get listings from local database           |
| `getListingById()`                | Query   | Get a single listing by UUID               |
| `getListingBySku()`               | Query   | Get a single listing by SKU                |
| `syncListings()`                  | Action  | Sync listings to local database            |
| `updateListing()`                 | Action  | Update a single listing (price, qty, etc.) |
| `createOrUpdateListings()`        | Action  | Bulk create/update via CSV                 |
| `getTaskStatus()`                 | Action  | Check status of bulk operation             |
| `createOrUpdateListingsAndSync()` | Action  | Bulk update + wait + sync (all-in-one)     |

#### Updating a Single Listing

```ts
// Update stock quantity
const result = await backMarket.updateListing(ctx, {
  organizationId: "org_123",
  listingId: "7a97a1b6-547e-4718-bc72-5b84aca91b09",
  body: {
    quantity: 50,
    price: "199.99",
    currency: "EUR",
  },
  environment: "production",
});

// Take listing offline (set quantity to 0)
await backMarket.updateListing(ctx, {
  organizationId: "org_123",
  listingId: "7a97a1b6-547e-4718-bc72-5b84aca91b09",
  body: { quantity: 0 },
  environment: "production",
});
```

#### Bulk Update Listings via CSV

```ts
// Create CSV content
const csvContent = `sku,quantity,price
MY_SKU_1,50,123.45
MY_SKU_2,25,99.99`;

// All-in-one: submit, wait for completion, sync
const result = await backMarket.createOrUpdateListingsAndSync(ctx, {
  organizationId: "org_123",
  body: {
    catalog: csvContent,
    quotechar: '"',
    delimiter: ",",
    encoding: "utf-8",
  },
  maxWaitMs: 10 * 60 * 1000, // 10 minutes
  pollIntervalMs: 15 * 1000, // 15 seconds
  environment: "production",
});

if (result.success) {
  console.log(`Synced ${result.syncResult?.totalListings} listings`);
}
```

### BackShip Delivery Methods

| Method                     | Context | Description                            |
| -------------------------- | ------- | -------------------------------------- |
| `getDeliveries()`          | Query   | Get deliveries from local database     |
| `getDeliveryById()`        | Query   | Get a single delivery by shipment ID   |
| `getDeliveriesByOrderId()` | Query   | Get all deliveries for an order        |
| `getLabelUrl()`            | Query   | Get signed URL for shipping label      |
| `syncDeliveries()`         | Action  | Sync deliveries + download labels      |
| `downloadLabel()`          | Action  | Download a specific shipping label     |
| `fetchDelivery()`          | Action  | Fetch single delivery from API + label |

#### Syncing Deliveries

```ts
const result = await backMarket.syncDeliveries(ctx, {
  organizationId: "org_123",
  downloadLabels: true, // default: true
  environment: "production",
});

console.log(`Synced ${result.totalDeliveries} deliveries`);
console.log(`Downloaded ${result.labelsDownloaded} labels`);
```

#### Getting a Shipping Label URL

```ts
const result = await backMarket.getLabelUrl(ctx, {
  organizationId: "org_123",
  shipmentId: 12345,
});

if (result.success) {
  // Open or download the label PDF
  window.open(result.url);
}
```

### BackShip Return Methods

| Method                      | Context | Description                          |
| --------------------------- | ------- | ------------------------------------ |
| `getReturns()`              | Query   | Get returns from local database      |
| `getReturnById()`           | Query   | Get a single return by shipment ID   |
| `getReturnsByOrderId()`     | Query   | Get all returns for an order         |
| `getReturnsByOrderlineId()` | Query   | Get all returns for an orderline     |
| `getReturnLabelUrl()`       | Query   | Get signed URL for return label      |
| `syncReturns()`             | Action  | Sync returns + download labels       |
| `downloadReturnLabel()`     | Action  | Download a specific return label     |
| `fetchReturn()`             | Action  | Fetch single return from API + label |

#### Syncing Returns

```ts
const result = await backMarket.syncReturns(ctx, {
  organizationId: "org_123",
  downloadLabels: true,
  environment: "production",
});

console.log(`Synced ${result.totalReturns} returns`);
console.log(`Downloaded ${result.labelsDownloaded} labels`);
```

## Automatic Data Sync

The component includes cron jobs that automatically sync data every 5 minutes
for all enabled connections:

- **Orders & Orderlines** - Synced together automatically
- **Listings** - Synced automatically

You can still trigger manual syncs with `syncOrders`, `syncListings`,
`syncDeliveries`, and `syncReturns` if needed.

## Database Schema

The component creates these tables in its namespace:

### connections

| Field                  | Type      | Description                                |
| ---------------------- | --------- | ------------------------------------------ |
| `organizationId`       | `string`  | Unique identifier for the organization     |
| `enabled`              | `boolean` | Whether the connection is active           |
| `apiKey`               | `string?` | Back Market API key (stored securely)      |
| `lastOrdersSyncAt`     | `number?` | Timestamp of last successful order sync    |
| `lastListingsSyncAt`   | `number?` | Timestamp of last successful listings sync |
| `lastDeliveriesSyncAt` | `number?` | Timestamp of last deliveries sync          |
| `lastReturnsSyncAt`    | `number?` | Timestamp of last returns sync             |

### orders

| Field              | Type       | Description                       |
| ------------------ | ---------- | --------------------------------- |
| `organizationId`   | `string`   | Organization that owns this order |
| `orderId`          | `number?`  | Back Market order ID              |
| `state`            | `number?`  | Order state (0, 1, 3, 8, 9, 10)   |
| `countryCode`      | `string?`  | Country code (e.g., "fr-fr")      |
| `dateCreation`     | `string?`  | Order creation date               |
| `dateModification` | `string?`  | Last modification date            |
| `dateShipping`     | `string?`  | Shipping date                     |
| `trackingNumber`   | `string?`  | Package tracking number           |
| `trackingUrl`      | `string?`  | Package tracking URL              |
| `shipperDisplay`   | `string?`  | Shipper name                      |
| `isBackship`       | `boolean?` | Whether using BackShip            |
| `shippingAddress`  | `object?`  | Customer shipping address         |
| `billingAddress`   | `object?`  | Customer billing address          |
| `lastSyncedAt`     | `number?`  | When this order was last synced   |

### orderlines

| Field            | Type      | Description                        |
| ---------------- | --------- | ---------------------------------- |
| `organizationId` | `string`  | Organization that owns this record |
| `orderId`        | `number`  | Parent order ID                    |
| `orderlineId`    | `number`  | Back Market orderline ID           |
| `state`          | `number?` | Orderline state (0-9)              |
| `listing`        | `string?` | SKU/listing identifier             |
| `product`        | `string?` | Product name                       |
| `quantity`       | `number?` | Quantity ordered                   |
| `price`          | `string?` | Price per item                     |
| `currency`       | `string?` | Currency code                      |
| `imei`           | `string?` | IMEI (for smartphones)             |
| `serialNumber`   | `string?` | Serial number                      |
| `lastSyncedAt`   | `number?` | When this was last synced          |

### listings

| Field              | Type      | Description                         |
| ------------------ | --------- | ----------------------------------- |
| `organizationId`   | `string`  | Organization that owns this listing |
| `listingId`        | `string?` | Back Market listing UUID            |
| `sku`              | `string?` | Merchant SKU                        |
| `title`            | `string?` | Product title                       |
| `price`            | `string?` | Current price                       |
| `currency`         | `string?` | Currency code                       |
| `quantity`         | `number?` | Available stock                     |
| `publicationState` | `number?` | Publication state (0-4)             |
| `state`            | `number?` | Condition state                     |
| `grade`            | `string?` | Grade (e.g., "A", "B")              |
| `lastSyncedAt`     | `number?` | When this was last synced           |

### deliveries

| Field            | Type       | Description                     |
| ---------------- | ---------- | ------------------------------- |
| `organizationId` | `string`   | Organization that owns this     |
| `shipmentId`     | `number`   | Back Market shipment ID         |
| `orderId`        | `number`   | Related order ID                |
| `orderlines`     | `number[]` | Related orderline IDs           |
| `carrierName`    | `string?`  | Carrier name                    |
| `trackingNumber` | `string?`  | Tracking number                 |
| `trackingUrl`    | `string?`  | Tracking URL                    |
| `labelUrl`       | `string?`  | Label URL from Back Market      |
| `labelStorageId` | `string?`  | Convex storage ID for label PDF |
| `lastSyncedAt`   | `number?`  | When this was last synced       |

### returns

| Field            | Type      | Description                     |
| ---------------- | --------- | ------------------------------- |
| `organizationId` | `string`  | Organization that owns this     |
| `shipmentId`     | `number`  | Back Market shipment ID         |
| `orderId`        | `number`  | Related order ID                |
| `orderlineId`    | `number`  | Related orderline ID            |
| `carrierName`    | `string?` | Carrier name                    |
| `trackingNumber` | `string?` | Tracking number                 |
| `trackingUrl`    | `string?` | Tracking URL                    |
| `label`          | `string?` | Return label URL                |
| `labelStorageId` | `string?` | Convex storage ID for label PDF |
| `lastSyncedAt`   | `number?` | When this was last synced       |

## Connection Lifecycle

The component supports a full connection lifecycle:

```
┌─────────────────┐
│   No Connection │
└────────┬────────┘
         │ upsertConnection()
         ▼
┌─────────────────┐
│    Connected    │◄───────────────┐
│   (enabled)     │                │
└────────┬────────┘                │
         │ disconnect()            │ reconnect()
         ▼                         │
┌─────────────────┐                │
│  Disconnected   │────────────────┘
│  (disabled)     │
└────────┬────────┘
         │ removeConnection()
         ▼
┌─────────────────┐
│    Deleted      │
└─────────────────┘
```

- **upsertConnection**: Create a new connection or update an existing one
- **disconnect**: Disable the connection (optionally clear the API key)
- **reconnect**: Re-enable a disabled connection (if API key is still stored)
- **removeConnection**: Permanently delete the connection and all its data

## Environment Configuration

The component supports both sandbox and production environments:

```ts
// Use sandbox for testing
await backMarket.syncOrders(ctx, {
  organizationId: "org_123",
  environment: "sandbox",
});

// Use production for real data
await backMarket.syncOrders(ctx, {
  organizationId: "org_123",
  environment: "production",
});
```

## Typed API Client

The component also exports a typed API client for direct Back Market API access:

```ts
import { createBackmarketClient } from "@maxime-ns/back-market";

// Create a client with your API key
const client = createBackmarketClient({
  apiKey: "your-api-key",
  environment: "sandbox",
  locale: "en-US",
});

// Make typed API calls
const { data, error } = await client.GET("/ws/orders", {
  params: {
    query: { state: 1, page: 1 },
  },
});
```

This client is generated from the
[Back Market OpenAPI spec](https://api.backmarket.dev/) and provides full type
safety.

## Example App

Check out the full example app in the [`example/`](./example/) directory:

```bash
git clone https://github.com/Maxime-NS/backmarket-component
cd backmarket-component
npm install
npm run dev
```

The example includes:

- Connection management UI
- Order listing with filters
- Manual sync functionality
- Environment switching

## Troubleshooting

### Orders not syncing

1. Verify the connection is enabled:

   ```ts
   const status = await backMarket.getConnectionStatus(ctx, {
     organizationId: "your-org-id",
   });
   console.log(status); // Check enabled: true
   ```

2. Check if the API key is valid by trying a manual sync:
   ```ts
   const result = await backMarket.syncOrders(ctx, {
     organizationId: "your-org-id",
     environment: "sandbox",
   });
   console.log(result); // Check for errors
   ```

### "No connection found" errors

Make sure you've created a connection first:

```ts
await backMarket.upsertConnection(ctx, {
  organizationId: "your-org-id",
  apiKey: "your-api-key",
});
```

### "Connection is disabled" errors

Re-enable the connection:

```ts
await backMarket.reconnect(ctx, {
  organizationId: "your-org-id",
});
```

### BackShip labels not downloading

1. Ensure `downloadLabels` is not set to `false` in sync calls
2. Check that the delivery/return has a `labelUrl` from Back Market
3. Use `downloadLabel()` or `downloadReturnLabel()` to manually download

## Contributing

Found a bug? Feature request?
[File it here](https://github.com/Maxime-NS/backmarket-component/issues).

See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup.

## License

Apache-2.0