@porulle/plugin-channel-connector 0.69.0 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.d.ts CHANGED
@@ -664,6 +664,15 @@ export declare class ChannelConnectorService {
664
664
  * goods already on their way. A store with no connector able to cancel is left to its merchant.
665
665
  */
666
666
  cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>>;
667
+ /**
668
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
669
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
670
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
671
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
672
+ */
673
+ private recordChannelFulfillments;
674
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
675
+ private unfulfilledLines;
667
676
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
668
677
  isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
669
678
  exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
package/dist/service.js CHANGED
@@ -5,7 +5,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
5
5
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
6
6
  import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
7
7
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
8
- import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
8
+ import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
9
9
  import { planAbsentArchives } from "./deletion-policy.js";
10
10
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
11
11
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
@@ -34,6 +34,15 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
34
34
  * cancelling at the store for exactly this reason, so the two directions cannot loop.
35
35
  */
36
36
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
37
+ /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
38
+ const channelFulfillmentsSchema = z.array(z.object({
39
+ id: z.union([z.string(), z.number()]),
40
+ status: z.string().nullish(),
41
+ tracking_company: z.string().nullish(),
42
+ tracking_number: z.string().nullish(),
43
+ tracking_url: z.string().nullish(),
44
+ line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
45
+ }));
37
46
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
38
47
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
39
48
  export function catalogPushRetryDelayMs(attempts) {
@@ -2274,7 +2283,9 @@ export class ChannelConnectorService {
2274
2283
  "products/delete",
2275
2284
  "inventory_levels/update",
2276
2285
  "orders/fulfilled",
2286
+ "orders/partially_fulfilled",
2277
2287
  "orders/cancelled",
2288
+ "refunds/create",
2278
2289
  "app/uninstalled",
2279
2290
  ], callbackUrl);
2280
2291
  if (!registration.ok) {
@@ -3880,7 +3891,7 @@ export class ChannelConnectorService {
3880
3891
  await this.setMappedInventory(orgId, storeId, externalId, available, actor);
3881
3892
  }
3882
3893
  }
3883
- else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
3894
+ else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
3884
3895
  const orderId = await this.resolveOrderId(orgId, storeId, data);
3885
3896
  if (orderId) {
3886
3897
  const ordersService = this.services.orders;
@@ -3896,13 +3907,19 @@ export class ChannelConnectorService {
3896
3907
  await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
3897
3908
  }
3898
3909
  }
3899
- if (event.type === "orders/fulfilled") {
3910
+ if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
3911
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
3912
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
3913
+ if (!recorded.ok)
3914
+ return recorded;
3915
+ const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
3900
3916
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3901
3917
  if (order?.status === "confirmed")
3902
3918
  await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
3903
3919
  const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3904
- if (after?.status === "processing")
3905
- await ordersService.changeStatus({ orderId, newStatus: "fulfilled", reason: "channel_order_fulfilled" }, actor);
3920
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
3921
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
3922
+ }
3906
3923
  }
3907
3924
  }
3908
3925
  }
@@ -4170,7 +4187,15 @@ export class ChannelConnectorService {
4170
4187
  const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
4171
4188
  if (existing[0])
4172
4189
  return Ok(existing[0]);
4173
- const lineData = Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
4190
+ // Shopify names the refunded lines in `refund_line_items`, each with its order line under
4191
+ // `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
4192
+ const lineData = Array.isArray(data.refund_line_items)
4193
+ ? data.refund_line_items.map((raw) => {
4194
+ const entry = (raw ?? {});
4195
+ const orderLine = (entry.line_item ?? {});
4196
+ return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
4197
+ })
4198
+ : Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
4174
4199
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4175
4200
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
4176
4201
  const refundLines = [];
@@ -4325,6 +4350,69 @@ export class ChannelConnectorService {
4325
4350
  }
4326
4351
  return Ok(cancelled);
4327
4352
  }
4353
+ /**
4354
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
4355
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
4356
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
4357
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
4358
+ */
4359
+ async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
4360
+ const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
4361
+ if (!parsed.success)
4362
+ return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
4363
+ const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
4364
+ const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
4365
+ const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4366
+ const fulfillment = this.services.fulfillment;
4367
+ let created = 0;
4368
+ for (const parcel of parsed.data) {
4369
+ const parcelId = String(parcel.id);
4370
+ if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
4371
+ continue;
4372
+ const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
4373
+ const mapped = externalIds.length === 0 ? [] : await this.db
4374
+ .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
4375
+ .from(channelEntityMap)
4376
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
4377
+ const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
4378
+ const matched = parcel.line_items.flatMap((line) => {
4379
+ const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
4380
+ const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
4381
+ return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
4382
+ });
4383
+ const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
4384
+ if (lineItems.length === 0)
4385
+ continue;
4386
+ const result = await fulfillment.createFulfillment({
4387
+ orderId,
4388
+ lineItems,
4389
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
4390
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
4391
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
4392
+ status: "shipped",
4393
+ metadata: { channelFulfillmentId: parcelId, storeId },
4394
+ }, actor);
4395
+ if (!result.ok)
4396
+ return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
4397
+ recorded.add(parcelId);
4398
+ created += 1;
4399
+ }
4400
+ return Ok(created);
4401
+ }
4402
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
4403
+ async unfulfilledLines(lines) {
4404
+ const ids = lines.map((line) => line.id);
4405
+ const shipped = ids.length === 0 ? [] : await this.db
4406
+ .select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql `coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
4407
+ .from(fulfillmentLineItems)
4408
+ .where(inArray(fulfillmentLineItems.orderLineItemId, ids))
4409
+ .groupBy(fulfillmentLineItems.orderLineItemId);
4410
+ const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
4411
+ return lines.flatMap((line) => {
4412
+ const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
4413
+ return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
4414
+ });
4415
+ }
4328
4416
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
4329
4417
  async isOrderClosed(orgId, orderId) {
4330
4418
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.69.0",
3
+ "version": "0.70.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.69.0"
25
+ "@porulle/core": "0.70.1"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/service.ts CHANGED
@@ -56,6 +56,8 @@ import {
56
56
  mediaAssets,
57
57
  optionTypes,
58
58
  optionValues,
59
+ fulfillmentLineItems,
60
+ fulfillmentRecords,
59
61
  orderLineItems,
60
62
  orders,
61
63
  prices,
@@ -130,6 +132,16 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
130
132
  */
131
133
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
132
134
 
135
+ /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
136
+ const channelFulfillmentsSchema = z.array(z.object({
137
+ id: z.union([z.string(), z.number()]),
138
+ status: z.string().nullish(),
139
+ tracking_company: z.string().nullish(),
140
+ tracking_number: z.string().nullish(),
141
+ tracking_url: z.string().nullish(),
142
+ line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
143
+ }));
144
+
133
145
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
134
146
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
135
147
 
@@ -3189,7 +3201,9 @@ export class ChannelConnectorService {
3189
3201
  "products/delete",
3190
3202
  "inventory_levels/update",
3191
3203
  "orders/fulfilled",
3204
+ "orders/partially_fulfilled",
3192
3205
  "orders/cancelled",
3206
+ "refunds/create",
3193
3207
  "app/uninstalled",
3194
3208
  ], callbackUrl);
3195
3209
  if (!registration.ok) {
@@ -4985,10 +4999,10 @@ export class ChannelConnectorService {
4985
4999
  const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
4986
5000
  await this.setMappedInventory(orgId, storeId, externalId, available, actor);
4987
5001
  }
4988
- } else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
5002
+ } else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
4989
5003
  const orderId = await this.resolveOrderId(orgId, storeId, data);
4990
5004
  if (orderId) {
4991
- const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
5005
+ const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "partially_fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
4992
5006
  const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
4993
5007
  if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
4994
5008
  if (event.type === "orders/cancelled") {
@@ -5000,11 +5014,17 @@ export class ChannelConnectorService {
5000
5014
  await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
5001
5015
  }
5002
5016
  }
5003
- if (event.type === "orders/fulfilled") {
5017
+ if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
5018
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
5019
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
5020
+ if (!recorded.ok) return recorded;
5021
+ const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
5004
5022
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5005
5023
  if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
5006
5024
  const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5007
- if (after?.status === "processing") await ordersService.changeStatus({ orderId, newStatus: "fulfilled", reason: "channel_order_fulfilled" }, actor);
5025
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
5026
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
5027
+ }
5008
5028
  }
5009
5029
  }
5010
5030
  } else if (event.type === "refunds/create") {
@@ -5294,7 +5314,15 @@ export class ChannelConnectorService {
5294
5314
  if (!remoteRefundId || !orderId) return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
5295
5315
  const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
5296
5316
  if (existing[0]) return Ok(existing[0] as ChannelRefundRequest);
5297
- const lineData = Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
5317
+ // Shopify names the refunded lines in `refund_line_items`, each with its order line under
5318
+ // `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
5319
+ const lineData = Array.isArray(data.refund_line_items)
5320
+ ? data.refund_line_items.map((raw) => {
5321
+ const entry = (raw ?? {}) as Record<string, unknown>;
5322
+ const orderLine = (entry.line_item ?? {}) as Record<string, unknown>;
5323
+ return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
5324
+ })
5325
+ : Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
5298
5326
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5299
5327
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
5300
5328
  const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
@@ -5470,6 +5498,67 @@ export class ChannelConnectorService {
5470
5498
  return Ok(cancelled);
5471
5499
  }
5472
5500
 
5501
+ /**
5502
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
5503
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
5504
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
5505
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
5506
+ */
5507
+ private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
5508
+ const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
5509
+ if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
5510
+ const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
5511
+ const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
5512
+ const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5513
+ const fulfillment = this.services.fulfillment as { createFulfillment(input: { orderId: string; lineItems: Array<{ orderLineItemId: string; quantity: number }>; carrier?: string; trackingNumber?: string; trackingUrl?: string; status?: string; metadata?: Record<string, unknown> }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5514
+ let created = 0;
5515
+ for (const parcel of parsed.data) {
5516
+ const parcelId = String(parcel.id);
5517
+ if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
5518
+ const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
5519
+ const mapped = externalIds.length === 0 ? [] : await this.db
5520
+ .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
5521
+ .from(channelEntityMap)
5522
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
5523
+ const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
5524
+ const matched = parcel.line_items.flatMap((line) => {
5525
+ const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
5526
+ const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
5527
+ return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
5528
+ });
5529
+ const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
5530
+ if (lineItems.length === 0) continue;
5531
+ const result = await fulfillment.createFulfillment({
5532
+ orderId,
5533
+ lineItems,
5534
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
5535
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
5536
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
5537
+ status: "shipped",
5538
+ metadata: { channelFulfillmentId: parcelId, storeId },
5539
+ }, actor);
5540
+ if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
5541
+ recorded.add(parcelId);
5542
+ created += 1;
5543
+ }
5544
+ return Ok(created);
5545
+ }
5546
+
5547
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
5548
+ private async unfulfilledLines(lines: Array<{ id: string; quantity: number }>): Promise<Array<{ orderLineItemId: string; quantity: number }>> {
5549
+ const ids = lines.map((line) => line.id);
5550
+ const shipped = ids.length === 0 ? [] : await this.db
5551
+ .select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql<number>`coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
5552
+ .from(fulfillmentLineItems)
5553
+ .where(inArray(fulfillmentLineItems.orderLineItemId, ids))
5554
+ .groupBy(fulfillmentLineItems.orderLineItemId);
5555
+ const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
5556
+ return lines.flatMap((line) => {
5557
+ const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
5558
+ return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
5559
+ });
5560
+ }
5561
+
5473
5562
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
5474
5563
  async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5475
5564
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));