@tokenoftrust/storefront-runner 6.5.0 → 6.5.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.
@@ -51,6 +51,25 @@ import {
51
51
  type FulfillmentDatabase,
52
52
  type SqlStatement,
53
53
  } from "./store.js";
54
+ import {
55
+ assignmentAtGate,
56
+ insertAssignmentStatement,
57
+ moveAssignmentStatement,
58
+ processorGuard,
59
+ readOpenAssignment,
60
+ readProcessor,
61
+ type AssignmentState,
62
+ type OpenAssignment,
63
+ type ProcessorConfig,
64
+ } from "./assignmentStore.js";
65
+ import {
66
+ CommercePayloadRefused,
67
+ commerceEventStatements,
68
+ commerceTargetsQuery,
69
+ } from "../integrations/webhooks/deliveryStore.js";
70
+ import { dispatchCommerceDeliveries } from "./commerceRuntime.js";
71
+ import type { CommerceTopic } from "../integrations/webhooks/commercePayloads.js";
72
+ import { claimedByEventGate, handoffClaimStatement } from "../integrations/webhooks/orderFulfilledClaim.js";
54
73
 
55
74
  export const OPAQUE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
56
75
 
@@ -124,6 +143,10 @@ export interface FulfillmentView {
124
143
  evidence: CommerceEvidence;
125
144
  placedAt: string;
126
145
  currency: string;
146
+ /** The (tenant, stage) processor install, when one is configured. */
147
+ processor: { installId: string; notifiable: boolean } | null;
148
+ /** The order's open assignment and the install that owns it. */
149
+ assignment: { assignmentId: string; installId: string; state: "active" | "held" } | null;
127
150
  }
128
151
 
129
152
  export interface FulfillmentBatch {
@@ -183,13 +206,38 @@ interface LoadedOrder {
183
206
  prohibitedCarriers: string[];
184
207
  adultSignatureRequired: boolean;
185
208
  auditCount: number;
209
+ processor: ProcessorConfig | null;
210
+ assignment: OpenAssignment | null;
186
211
  view: FulfillmentView;
187
212
  }
188
213
 
214
+ interface PlannedDelivery {
215
+ index: number;
216
+ deliveryId: string;
217
+ }
218
+
189
219
  type CommitPlan =
190
220
  | { type: "done"; view: FulfillmentView }
191
221
  | { type: "refuse"; code: string; holds?: readonly ReleaseHold[] }
192
- | { type: "write"; statements: SqlStatement[]; minimumChanges: number; sourceAdvance?:boolean };
222
+ | {
223
+ type: "write";
224
+ statements: SqlStatement[];
225
+ minimumChanges: number;
226
+ sourceAdvance?: boolean;
227
+ /** Delivery rows the batch writes; the committed ones are handed to `notify`. */
228
+ deliveries?: readonly PlannedDelivery[];
229
+ /** An assignment plan: a CAS miss re-runs build (bound to its install) even with expectedVersion. */
230
+ assignment?: boolean;
231
+ };
232
+
233
+ /** Statement and delivery accumulator for a plan that may carry commerce events. */
234
+ interface PlanStatements {
235
+ statements: SqlStatement[];
236
+ deliveries: PlannedDelivery[];
237
+ }
238
+
239
+ /** Statuses an open assignment reports in its lifecycle event. */
240
+ type ReportedAssignmentState = Exclude<AssignmentState, "released">;
193
241
 
194
242
  const FAIL_CLOSED_EVIDENCE: CommerceEvidence = {
195
243
  verification: "unreadable",
@@ -468,7 +516,11 @@ export async function openFulfillment(
468
516
  compliance: tenant?.compliance,
469
517
  carrierAccountId: tenant?.carrierAccountId,
470
518
  });
471
- return { ok: true, actor: decision.actor, service: new FulfillmentService(fulfillmentDbFromD1(d1), { policy }) };
519
+ return {
520
+ ok: true,
521
+ actor: decision.actor,
522
+ service: new FulfillmentService(fulfillmentDbFromD1(d1), { policy, notify: dispatchCommerceDeliveries }),
523
+ };
472
524
  }
473
525
 
474
526
  export function parseFulfillmentBody(body: unknown): FulfillmentResult<{
@@ -533,7 +585,12 @@ export class FulfillmentService {
533
585
 
534
586
  constructor(
535
587
  private readonly db: FulfillmentDatabase,
536
- private readonly options: { policy: CarrierPolicy; now?: () => string },
588
+ private readonly options: {
589
+ policy: CarrierPolicy;
590
+ now?: () => string;
591
+ /** Called after a commit with the delivery ids it wrote, to enqueue them and arm the commerce alarm. */
592
+ notify?: (tenantId: string, deliveryIds: string[]) => void | Promise<void>;
593
+ },
537
594
  ) {
538
595
  this.now = options.now ?? (() => new Date().toISOString());
539
596
  }
@@ -673,20 +730,16 @@ export class FulfillmentService {
673
730
  if (state.order.fulfillment === "handed_off" || state.order.fulfillment === "cancelled") return { type: "refuse", code: "fulfillment" };
674
731
  if (state.operatorHold) return { type: "done", view: state.view };
675
732
  const version = state.order.eventVersion + 1;
676
- return {
677
- type: "write",
678
- minimumChanges: 1,
679
- statements: [
680
- {
681
- sql: `UPDATE scrappy_commerce_orders
682
- SET fulfillment_state = 'held', event_version = event_version + 1
683
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
684
- AND fulfillment_state IN ('ready', 'held')`,
685
- params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
686
- },
687
- auditStatement(state, actor.actorId, "operator-hold", state.order.fulfillment, "open", version, stamp(this.now(), state.auditCount + 1)),
688
- ],
689
- };
733
+ return this.withAssignmentHeld(state, version, [
734
+ {
735
+ sql: `UPDATE scrappy_commerce_orders
736
+ SET fulfillment_state = 'held', event_version = event_version + 1
737
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
738
+ AND fulfillment_state IN ('ready', 'held')`,
739
+ params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
740
+ },
741
+ auditStatement(state, actor.actorId, "operator-hold", state.order.fulfillment, "open", version, stamp(this.now(), state.auditCount + 1)),
742
+ ]);
690
743
  });
691
744
  }
692
745
 
@@ -696,9 +749,7 @@ export class FulfillmentService {
696
749
  if (state.order.fulfillment === "handed_off") return { type: "refuse", code: "fulfillment" };
697
750
  const next = projectedFulfillment(state, false, state.cancelPending);
698
751
  const version = state.order.eventVersion + 1;
699
- return {
700
- type: "write",
701
- minimumChanges: 1,
752
+ const plan: PlanStatements = {
702
753
  statements: [
703
754
  {
704
755
  sql: `UPDATE scrappy_commerce_orders
@@ -709,7 +760,30 @@ export class FulfillmentService {
709
760
  },
710
761
  auditStatement(state, actor.actorId, "operator-hold", "open", "cleared", version, stamp(this.now(), state.auditCount + 1)),
711
762
  ],
763
+ deliveries: [],
712
764
  };
765
+ const assignment = state.assignment;
766
+ if (assignment?.state === "held" && !state.cancelPending && state.order.payment !== "refund_requested") {
767
+ // Resume only while the install is still the configured, notifiable processor; otherwise
768
+ // release it so the order waits for a new operator release.
769
+ const guard = processorGuard(state.order.tenantId, state.order.stage, assignment.installId);
770
+ this.appendAssignmentMove(plan, state, assignment, "active", version, guard);
771
+ plan.statements.push(
772
+ moveAssignmentStatement({
773
+ assignmentId: assignment.assignmentId,
774
+ tenantId: state.order.tenantId,
775
+ stage: state.order.stage,
776
+ orderId: state.order.orderId,
777
+ from: ["held"],
778
+ to: "released",
779
+ version,
780
+ now: this.now(),
781
+ guard,
782
+ unlessGuard: true,
783
+ }),
784
+ );
785
+ }
786
+ return { type: "write", minimumChanges: 1, statements: plan.statements, deliveries: plan.deliveries };
713
787
  });
714
788
  }
715
789
 
@@ -727,28 +801,24 @@ export class FulfillmentService {
727
801
  });
728
802
  if (!decision.admit) return { type: "refuse", code: decision.reason };
729
803
  const version = state.order.eventVersion + 1;
730
- return {
731
- type: "write",
732
- minimumChanges: 1,
733
- statements: [
734
- {
735
- sql: `UPDATE scrappy_commerce_orders
736
- SET fulfillment_state = 'held', event_version = event_version + 1
737
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
738
- AND fulfillment_state IN ('ready', 'held')`,
739
- params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
740
- },
741
- auditStatement(
742
- state,
743
- actor.actorId,
744
- "pending-cancel",
745
- decision.releaseReservation ? "release-reservation" : "keep-reservation",
746
- "open",
747
- version,
748
- stamp(this.now(), state.auditCount + 1),
749
- ),
750
- ],
751
- };
804
+ return this.withAssignmentHeld(state, version, [
805
+ {
806
+ sql: `UPDATE scrappy_commerce_orders
807
+ SET fulfillment_state = 'held', event_version = event_version + 1
808
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
809
+ AND fulfillment_state IN ('ready', 'held')`,
810
+ params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
811
+ },
812
+ auditStatement(
813
+ state,
814
+ actor.actorId,
815
+ "pending-cancel",
816
+ decision.releaseReservation ? "release-reservation" : "keep-reservation",
817
+ "open",
818
+ version,
819
+ stamp(this.now(), state.auditCount + 1),
820
+ ),
821
+ ]);
752
822
  });
753
823
  }
754
824
 
@@ -771,21 +841,17 @@ export class FulfillmentService {
771
841
  });
772
842
  if (!decision.admit) return { type: "refuse", code: decision.reason };
773
843
  const version = state.order.eventVersion + 1;
774
- return {
775
- type: "write",
776
- minimumChanges: 1,
777
- statements: [
778
- {
779
- sql: `UPDATE scrappy_commerce_orders
780
- SET payment_state = 'refund_requested', fulfillment_state = 'held', event_version = event_version + 1
781
- WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
782
- AND payment_state = 'captured'
783
- AND fulfillment_state NOT IN ('handed_off', 'cancelled')`,
784
- params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
785
- },
786
- auditStatement(state, actor.actorId, "pending-refund", state.order.payment, "open", version, stamp(this.now(), state.auditCount + 1)),
787
- ],
788
- };
844
+ return this.withAssignmentHeld(state, version, [
845
+ {
846
+ sql: `UPDATE scrappy_commerce_orders
847
+ SET payment_state = 'refund_requested', fulfillment_state = 'held', event_version = event_version + 1
848
+ WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
849
+ AND payment_state = 'captured'
850
+ AND fulfillment_state NOT IN ('handed_off', 'cancelled')`,
851
+ params: [state.order.tenantId, state.order.stage, state.order.orderId, state.order.eventVersion],
852
+ },
853
+ auditStatement(state, actor.actorId, "pending-refund", state.order.payment, "open", version, stamp(this.now(), state.auditCount + 1)),
854
+ ]);
789
855
  });
790
856
  }
791
857
 
@@ -796,6 +862,7 @@ export class FulfillmentService {
796
862
  if (!oneOf(input.adultSignature, ["accepted", "refused", "absent"] as const)) return refuse("malformed");
797
863
  const mapped = labelOutcomeColumns(input.outcome);
798
864
  return this.commit(actor, orderId, input.expectedVersion, (state) => {
865
+ if (state.assignment) return { type: "refuse", code: "processor-assigned" };
799
866
  if (!state.shipment || !state.provider) return { type: "refuse", code: "label" };
800
867
  if (state.order.fulfillment === "handed_off") return { type: "refuse", code: "fulfillment" };
801
868
  if (state.shipment.labelPurchaseState === mapped.shipment && state.labelOutcome?.adultSignature === input.adultSignature) {
@@ -877,7 +944,8 @@ export class FulfillmentService {
877
944
  async handOff(actor: FulfillmentActor, orderId: string, options: FulfillmentCommandOptions = {}): Promise<FulfillmentResult<FulfillmentView>> {
878
945
  const selected = carrierSelectionRefusal(this.policy, options.carrier);
879
946
  if (selected) return refuse(selected);
880
- return this.commit(actor, orderId, options.expectedVersion, (state) => {
947
+ return this.commit(actor, orderId, options.expectedVersion, async (state) => {
948
+ if (state.assignment) return { type: "refuse", code: "processor-assigned" };
881
949
  if (state.shipment?.handoffState === "handed_off" || state.order.fulfillment === "handed_off") {
882
950
  return { type: "done", view: state.view };
883
951
  }
@@ -887,10 +955,8 @@ export class FulfillmentService {
887
955
  return { type: "refuse", code: "label" };
888
956
  }
889
957
  const version = state.order.eventVersion + 1;
890
- return {
891
- type: "write",
892
- minimumChanges: 1,
893
- sourceAdvance:true,
958
+ const plan: PlanStatements = {
959
+ deliveries: [],
894
960
  statements: [
895
961
  {
896
962
  sql: `UPDATE scrappy_commerce_orders
@@ -936,6 +1002,8 @@ export class FulfillmentService {
936
1002
  terminalTransitionExpiryStatement(state.order, version, new Date(this.now())),
937
1003
  ],
938
1004
  };
1005
+ await this.appendOrderFulfilled(plan, state, version);
1006
+ return { type: "write", minimumChanges: 1, sourceAdvance: true, statements: plan.statements, deliveries: plan.deliveries };
939
1007
  });
940
1008
  }
941
1009
 
@@ -978,6 +1046,8 @@ export class FulfillmentService {
978
1046
  if (selected) return refuse(selected);
979
1047
  if (options.scope !== undefined && options.scope !== "whole-order") return refuse("partial-shipment");
980
1048
  if (options.lines !== undefined) return refuse("partial-shipment");
1049
+ // The install a release's first assignment plan targeted; a CAS-miss retry stays bound to it.
1050
+ let boundInstall: string | null = null;
981
1051
  return this.commit(actor, orderId, options.expectedVersion, async (state) => {
982
1052
  if (!state.batch) return { type: "refuse", code: "batch" };
983
1053
  if (state.order.fulfillment === "handed_off" || state.order.fulfillment === "cancelled") {
@@ -988,8 +1058,31 @@ export class FulfillmentService {
988
1058
  const readiness = this.readiness(state);
989
1059
  const releasable = canRelease(readiness);
990
1060
  if (raising && !releasable) return {type:"refuse",code:releaseHolds(readiness)[0] ?? "held",holds:releaseHolds(readiness)};
1061
+ if (phase === null && (boundInstall !== null || state.processor)) {
1062
+ const target = boundInstall ?? state.processor!.installId;
1063
+ // The install that already holds the order's assignment answers done at any version.
1064
+ if (state.assignment) {
1065
+ return state.assignment.installId === target ? { type: "done", view: state.view } : { type: "refuse", code: "assignment-conflict" };
1066
+ }
1067
+ // A desk release carries the version it saw; any other version lost to a concurrent change.
1068
+ if (options.expectedVersion !== undefined && state.order.eventVersion !== options.expectedVersion) {
1069
+ return { type: "refuse", code: "assignment-conflict" };
1070
+ }
1071
+ if (!state.processor || state.processor.installId !== target || !state.processor.notifiable) {
1072
+ return { type: "refuse", code: "assignment-conflict" };
1073
+ }
1074
+ if (state.shipment || holdsLiveLabelOperation(state)) return { type: "refuse", code: "label-intent" };
1075
+ if (!releasable) {
1076
+ const holds = releaseHolds(readiness);
1077
+ return { type: "refuse", code: holds[0] ?? "hold", holds };
1078
+ }
1079
+ boundInstall = target;
1080
+ return this.assignmentPlan(state, target);
1081
+ }
1082
+ if (phase === null && state.assignment) return { type: "refuse", code: "processor-assigned" };
991
1083
  const labelAcceptsIntent = state.order.label === "none" || state.order.label === "voided" || state.order.label === "intent";
992
- const wantIntent = (phase === null || phase === "picked" || phase === "packed") && releasable && !state.shipment && labelAcceptsIntent;
1084
+ const wantIntent = (phase === null || phase === "picked" || phase === "packed") && releasable && !state.shipment && labelAcceptsIntent
1085
+ && !state.processor && !state.assignment;
993
1086
  if (phase === null && !state.shipment && !wantIntent) {
994
1087
  if (!releasable) {
995
1088
  const holds = releaseHolds(readiness);
@@ -1050,6 +1143,11 @@ export class FulfillmentService {
1050
1143
  AND NOT EXISTS (
1051
1144
  SELECT 1 FROM scrappy_shipments AS s
1052
1145
  WHERE s.tenant_id = o.tenant_id AND s.stage = o.stage AND s.order_id = o.order_id
1146
+ )
1147
+ AND NOT EXISTS (
1148
+ SELECT 1 FROM fulfillment_assignments AS fa
1149
+ WHERE fa.tenant_id = o.tenant_id AND fa.stage = o.stage AND fa.order_id = o.order_id
1150
+ AND fa.state IN ('active', 'held')
1053
1151
  )`,
1054
1152
  params: [order.tenantId, order.stage, order.orderId, version],
1055
1153
  },
@@ -1080,12 +1178,20 @@ export class FulfillmentService {
1080
1178
  const scope = this.commandScope(actor);
1081
1179
  if (!scope) return refuse("denied");
1082
1180
  if (!isOpaqueId(orderId)) return refuse("not-found");
1181
+ let assignmentPlan = false;
1083
1182
  for (let attempt = 0; attempt < 5; attempt++) {
1084
1183
  const state = await this.load(scope.tenantId, scope.stage, orderId);
1085
1184
  if (!state) return refuse("not-found");
1086
- const plan = await build(state);
1185
+ let plan: CommitPlan;
1186
+ try {
1187
+ plan = await build(state);
1188
+ } catch (error) {
1189
+ if (error instanceof CommercePayloadRefused) return refuse("malformed");
1190
+ throw error;
1191
+ }
1087
1192
  if (plan.type === "done") return ok(plan.view);
1088
1193
  if (plan.type === "refuse") return refuse(plan.code, plan.holds);
1194
+ assignmentPlan = plan.assignment === true;
1089
1195
  if (expectedVersion !== undefined && state.order.eventVersion !== expectedVersion) return refuse("conflict");
1090
1196
  const fence=authorityFence(state.sourceAuthority,this.now(),plan.sourceAdvance===true);
1091
1197
  const first=plan.statements[0];
@@ -1094,12 +1200,205 @@ export class FulfillmentService {
1094
1200
  const changes = await this.write(guarded);
1095
1201
  if (!changes) return refuse("unavailable");
1096
1202
  if ((changes[0] ?? 0) >= plan.minimumChanges) {
1203
+ await this.notifyCommitted(scope.tenantId, plan.deliveries ?? [], changes);
1097
1204
  const next = await this.load(scope.tenantId, scope.stage, orderId);
1098
1205
  return next ? ok(next.view) : refuse("not-found");
1099
1206
  }
1100
- if (expectedVersion !== undefined) return refuse("conflict");
1207
+ if (expectedVersion !== undefined && !assignmentPlan) return refuse("conflict");
1101
1208
  }
1102
- return refuse("conflict");
1209
+ return refuse(assignmentPlan ? "assignment-conflict" : "conflict");
1210
+ }
1211
+
1212
+ /** A hold-shaped write: the order statements plus, for an active assignment, its move to held. */
1213
+ private withAssignmentHeld(state: LoadedOrder, version: number, statements: SqlStatement[]): CommitPlan {
1214
+ const plan: PlanStatements = { statements, deliveries: [] };
1215
+ if (state.assignment?.state === "active") this.appendAssignmentMove(plan, state, state.assignment, "held", version);
1216
+ return { type: "write", minimumChanges: 1, statements: plan.statements, deliveries: plan.deliveries };
1217
+ }
1218
+
1219
+ /**
1220
+ * Release to the configured processor: the order CAS (also requiring the config to name
1221
+ * `installId` and the install to be notifiable), the active assignment, and its one
1222
+ * `fulfillment.assignment.created` delivery to that install. No label intent is written.
1223
+ */
1224
+ private assignmentPlan(state: LoadedOrder, installId: string): CommitPlan {
1225
+ const { order } = state;
1226
+ const version = order.eventVersion + 1;
1227
+ const assignmentId = `asg_${crypto.randomUUID()}`;
1228
+ const touch = touchOrder(state, false);
1229
+ const guard = processorGuard(order.tenantId, order.stage, installId);
1230
+ const plan: PlanStatements = {
1231
+ statements: [
1232
+ { sql: `${touch.sql} AND ${guard.sql}`, params: [...touch.params, ...guard.params] },
1233
+ insertAssignmentStatement({
1234
+ assignmentId,
1235
+ tenantId: order.tenantId,
1236
+ stage: order.stage,
1237
+ orderId: order.orderId,
1238
+ installId,
1239
+ version,
1240
+ now: this.now(),
1241
+ }),
1242
+ ],
1243
+ deliveries: [],
1244
+ };
1245
+ this.appendCommerceEvent(plan, {
1246
+ tenantId: order.tenantId,
1247
+ topic: "fulfillment.assignment.created",
1248
+ payload: assignmentPayload(state, assignmentId, "active", version, this.now()),
1249
+ gate: assignmentAtGate(assignmentId, "active", version),
1250
+ installIds: [installId],
1251
+ });
1252
+ return {
1253
+ type: "write",
1254
+ minimumChanges: 1,
1255
+ sourceAdvance: true,
1256
+ assignment: true,
1257
+ statements: plan.statements,
1258
+ deliveries: plan.deliveries,
1259
+ };
1260
+ }
1261
+
1262
+ /** The handoff's `order.fulfilled`: claim it for this order, and write the event only under that claim. */
1263
+ private async appendOrderFulfilled(plan: PlanStatements, state: LoadedOrder, version: number): Promise<void> {
1264
+ const { order } = state;
1265
+ const key = { tenantId: order.tenantId, stage: order.stage, source: order.source, sourceOrderId: order.sourceOrderId };
1266
+ const eventId = crypto.randomUUID();
1267
+ plan.statements.push(
1268
+ handoffClaimStatement(key, eventId, this.now(), {
1269
+ sql: `EXISTS (SELECT 1 FROM scrappy_commerce_orders WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?)`,
1270
+ params: [order.tenantId, order.stage, order.orderId, version],
1271
+ }),
1272
+ );
1273
+ const targets = commerceTargetsQuery(order.tenantId, "order.fulfilled");
1274
+ const installs = await this.db.all<{ install_id: string }>(targets.sql, targets.params);
1275
+ const payload: Record<string, unknown> = { id: order.sourceOrderId, status: "fulfilled", currency: order.currency };
1276
+ if (state.lines.length > 0) payload.lineItems = state.lines.map((line) => ({ sku: line.sku, quantity: line.quantity }));
1277
+ this.appendCommerceEvent(plan, {
1278
+ tenantId: order.tenantId,
1279
+ topic: "order.fulfilled",
1280
+ payload,
1281
+ gate: claimedByEventGate(key, eventId),
1282
+ installIds: installs.map((row) => row.install_id),
1283
+ eventId,
1284
+ });
1285
+ }
1286
+
1287
+ /**
1288
+ * The processor's reconciliation read: the calling install's active assignments on this tenant
1289
+ * and stage whose order is still releasable at its current event version, oldest first.
1290
+ */
1291
+ async listAssignments(input: {
1292
+ tenantId: string;
1293
+ stage: CommerceStage;
1294
+ installId: string;
1295
+ limit: number;
1296
+ cursor: string | null;
1297
+ }): Promise<FulfillmentResult<{ assignments: AssignmentReadItem[]; nextCursor: string | null }>> {
1298
+ const limit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
1299
+ let after: [string, string] = ["", ""];
1300
+ if (input.cursor !== null) {
1301
+ const decoded = decodeAssignmentCursor(input.cursor);
1302
+ if (!decoded) return ok({ assignments: [], nextCursor: null });
1303
+ after = decoded;
1304
+ }
1305
+ const rows = await this.db.all<{ assignment_id: string; order_id: string; assigned_at: string }>(
1306
+ `SELECT assignment_id, order_id, assigned_at FROM fulfillment_assignments
1307
+ WHERE tenant_id = ? AND stage = ? AND install_id = ? AND state = 'active'
1308
+ AND (assigned_at > ? OR (assigned_at = ? AND assignment_id > ?))
1309
+ ORDER BY assigned_at ASC, assignment_id ASC LIMIT ?`,
1310
+ [input.tenantId, input.stage, input.installId, after[0], after[0], after[1], limit + 1],
1311
+ );
1312
+ const page = rows.slice(0, limit);
1313
+ const assignments: AssignmentReadItem[] = [];
1314
+ for (const row of page) {
1315
+ const state = await this.load(input.tenantId, input.stage, row.order_id);
1316
+ if (!state || state.assignment?.assignmentId !== row.assignment_id || state.assignment.installId !== input.installId) continue;
1317
+ if (state.assignment.state !== "active" || state.lines.length === 0) continue;
1318
+ if (!canRelease(this.readiness(state))) continue;
1319
+ assignments.push({
1320
+ assignmentId: row.assignment_id,
1321
+ orderId: row.order_id,
1322
+ stage: input.stage,
1323
+ eventVersion: state.order.eventVersion,
1324
+ assignedAt: row.assigned_at,
1325
+ purchaseId: await purchaseIdFor(input.tenantId, input.stage, row.order_id),
1326
+ lineItems: state.lines.map((line) => ({ sku: line.sku, quantity: line.quantity })),
1327
+ });
1328
+ }
1329
+ const last = page[page.length - 1];
1330
+ return ok({ assignments, nextCursor: rows.length > limit && last ? encodeAssignmentCursor(last.assigned_at, last.assignment_id) : null });
1331
+ }
1332
+
1333
+ private async notifyCommitted(tenantId: string, deliveries: readonly PlannedDelivery[], changes: readonly number[]): Promise<void> {
1334
+ const committed = deliveries.filter((delivery) => (changes[delivery.index] ?? 0) > 0).map((delivery) => delivery.deliveryId);
1335
+ if (committed.length === 0 || !this.options.notify) return;
1336
+ try {
1337
+ await this.options.notify(tenantId, committed);
1338
+ } catch {
1339
+ // The delivery rows are committed and pending; the tenant commerce alarm redelivers them.
1340
+ }
1341
+ }
1342
+
1343
+ /** Appends one commerce event (gated on `gate`) and a delivery per install to `plan`. */
1344
+ private appendCommerceEvent(
1345
+ plan: PlanStatements,
1346
+ input: {
1347
+ tenantId: string;
1348
+ topic: CommerceTopic;
1349
+ payload: Record<string, unknown>;
1350
+ gate: SqlStatement;
1351
+ installIds: readonly string[];
1352
+ eventId?: string;
1353
+ },
1354
+ ): void {
1355
+ const targets = input.installIds.map((installId) => ({ installId, deliveryId: crypto.randomUUID() }));
1356
+ const built = commerceEventStatements({
1357
+ eventId: input.eventId ?? crypto.randomUUID(),
1358
+ tenantId: input.tenantId,
1359
+ topic: input.topic,
1360
+ payload: input.payload,
1361
+ gate: { sql: input.gate.sql, params: [...input.gate.params] },
1362
+ targets,
1363
+ now: this.now(),
1364
+ });
1365
+ built.forEach((statement, index) => {
1366
+ const target = index > 0 ? targets[index - 1] : undefined;
1367
+ if (target) plan.deliveries.push({ index: plan.statements.length, deliveryId: target.deliveryId });
1368
+ plan.statements.push(statement);
1369
+ });
1370
+ }
1371
+
1372
+ /** Appends the assignment's move to `to` at `version` plus its `fulfillment.assignment.updated` event. */
1373
+ private appendAssignmentMove(
1374
+ plan: PlanStatements,
1375
+ state: LoadedOrder,
1376
+ assignment: OpenAssignment,
1377
+ to: ReportedAssignmentState,
1378
+ version: number,
1379
+ guard?: SqlStatement,
1380
+ ): void {
1381
+ const from: AssignmentState[] = to === "cancelled" ? ["active", "held"] : to === "held" ? ["active"] : ["held"];
1382
+ plan.statements.push(
1383
+ moveAssignmentStatement({
1384
+ assignmentId: assignment.assignmentId,
1385
+ tenantId: state.order.tenantId,
1386
+ stage: state.order.stage,
1387
+ orderId: state.order.orderId,
1388
+ from,
1389
+ to,
1390
+ version,
1391
+ now: this.now(),
1392
+ guard,
1393
+ }),
1394
+ );
1395
+ this.appendCommerceEvent(plan, {
1396
+ tenantId: state.order.tenantId,
1397
+ topic: "fulfillment.assignment.updated",
1398
+ payload: assignmentPayload(state, assignment.assignmentId, to, version, this.now()),
1399
+ gate: assignmentAtGate(assignment.assignmentId, to, version),
1400
+ installIds: [assignment.installId],
1401
+ });
1103
1402
  }
1104
1403
 
1105
1404
  private async write(statements: readonly SqlStatement[],minimumChanges=1): Promise<number[] | null> {
@@ -1292,7 +1591,9 @@ export class FulfillmentService {
1292
1591
  statements.push(terminalTransitionExpiryStatement(state.order, nextVersion, new Date(this.now())));
1293
1592
  }
1294
1593
  if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,nextVersion,this.now()));
1295
- return { type: "write", statements, minimumChanges: 1 };
1594
+ const plan: PlanStatements = { statements, deliveries: [] };
1595
+ if (confirmedCancel && state.assignment) this.appendAssignmentMove(plan, state, state.assignment, "cancelled", nextVersion);
1596
+ return { type: "write", statements: plan.statements, deliveries: plan.deliveries, minimumChanges: 1 };
1296
1597
  });
1297
1598
  }
1298
1599
 
@@ -1394,6 +1695,8 @@ export class FulfillmentService {
1394
1695
  try { sourceAuthority=await this.db.get<AuthorityRow & Record<string,unknown>>(`SELECT * FROM commerce_source_authority WHERE tenant_id=? AND stage=? AND source=? AND source_order_id=?`,[tenantId,stage,order.source,order.sourceOrderId]) ?? null;
1395
1696
  sourceHolds=sourceAuthority?.order_id!==order.orderId?["source-authority-unavailable"]:authorityHolds(sourceAuthority,this.now());
1396
1697
  }catch{sourceHolds=["source-authority-unreadable"];}
1698
+ const processor = await readProcessor(this.db, tenantId, stage);
1699
+ const assignment = await readOpenAssignment(this.db, tenantId, stage, orderId);
1397
1700
  const contacts = await readOrderContactAvailability({
1398
1701
  all: (sql, ...params) => this.db.all(sql, params),
1399
1702
  }, { tenantId, stage, orderIds: [orderId], now: new Date(this.now()) });
@@ -1417,6 +1720,8 @@ export class FulfillmentService {
1417
1720
  prohibitedCarriers: intentProhibited,
1418
1721
  adultSignatureRequired: intentAdult,
1419
1722
  auditCount: audits.length,
1723
+ processor,
1724
+ assignment,
1420
1725
  view: {
1421
1726
  orderId: order.orderId,
1422
1727
  tenantId: order.tenantId,
@@ -1434,13 +1739,15 @@ export class FulfillmentService {
1434
1739
  shipment: null,
1435
1740
  packingSlip,
1436
1741
  lines, evidence, placedAt:order.placedAt, currency:order.currency,
1742
+ processor,
1743
+ assignment: assignment ? { assignmentId: assignment.assignmentId, installId: assignment.installId, state: assignment.state } : null,
1437
1744
  },
1438
1745
  };
1439
1746
  const readiness = this.readiness(loaded);
1440
1747
  const holds = releaseHolds(readiness);
1441
1748
  loaded.view.holds = holds;
1442
1749
  if(holds.length&&order.fulfillment==="ready")loaded.view.fulfillment="held";
1443
- loaded.view.canRelease = canRelease(readiness) && !loaded.shipment;
1750
+ loaded.view.canRelease = canRelease(readiness) && !loaded.shipment && !assignment;
1444
1751
  loaded.view.canHandOff = handoffRefusal(readiness) === null && loaded.shipment?.handoffState !== "handed_off";
1445
1752
  loaded.view.shipment = loaded.shipment
1446
1753
  ? {
@@ -1458,6 +1765,56 @@ export class FulfillmentService {
1458
1765
  }
1459
1766
  }
1460
1767
 
1768
+ export interface AssignmentReadItem {
1769
+ assignmentId: string;
1770
+ orderId: string;
1771
+ stage: CommerceStage;
1772
+ eventVersion: number;
1773
+ assignedAt: string;
1774
+ purchaseId: string;
1775
+ lineItems: { sku: string; quantity: number }[];
1776
+ }
1777
+
1778
+ function encodeAssignmentCursor(assignedAt: string, assignmentId: string): string {
1779
+ return btoa(JSON.stringify([assignedAt, assignmentId])).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1780
+ }
1781
+
1782
+ function decodeAssignmentCursor(cursor: string): [string, string] | null {
1783
+ if (cursor.length === 0 || cursor.length > 512 || !/^[A-Za-z0-9_-]+$/.test(cursor)) return null;
1784
+ try {
1785
+ const parsed: unknown = JSON.parse(atob(cursor.replace(/-/g, "+").replace(/_/g, "/")));
1786
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
1787
+ const [assignedAt, assignmentId] = parsed as unknown[];
1788
+ return typeof assignedAt === "string" && typeof assignmentId === "string" ? [assignedAt, assignmentId] : null;
1789
+ } catch {
1790
+ return null;
1791
+ }
1792
+ }
1793
+
1794
+ /** `FulfillmentAssignmentData` (asyncapi.yaml): exactly these six keys. */
1795
+ function assignmentPayload(
1796
+ state: LoadedOrder,
1797
+ assignmentId: string,
1798
+ assignmentState: ReportedAssignmentState,
1799
+ version: number,
1800
+ now: string,
1801
+ ): Record<string, unknown> {
1802
+ return {
1803
+ assignmentId,
1804
+ orderId: state.order.orderId,
1805
+ stage: state.order.stage,
1806
+ state: assignmentState,
1807
+ eventVersion: version,
1808
+ occurredAt: now,
1809
+ };
1810
+ }
1811
+
1812
+ /** A whole-order label purchase is in flight or held by the provider. */
1813
+ function holdsLiveLabelOperation(state: LoadedOrder): boolean {
1814
+ const outcome = state.provider?.outcome;
1815
+ return outcome === "intent" || outcome === "accepted" || outcome === "unknown";
1816
+ }
1817
+
1461
1818
  function touchOrder(state: LoadedOrder, wantIntent: boolean): SqlStatement {
1462
1819
  return {
1463
1820
  sql: `UPDATE scrappy_commerce_orders
@@ -1639,11 +1996,13 @@ function parseLabelOutcome(value: unknown): { adultSignature: "accepted" | "refu
1639
1996
  return { adultSignature: value.adultSignature };
1640
1997
  }
1641
1998
 
1999
+ /** A stored audit state that is not JSON parses to null, which every caller fails closed on. */
1642
2000
  function parseJson(value: string): unknown {
1643
2001
  try {
1644
2002
  return JSON.parse(value) as unknown;
1645
- } catch {
1646
- return null;
2003
+ } catch (error) {
2004
+ if (error instanceof SyntaxError) return null;
2005
+ throw error;
1647
2006
  }
1648
2007
  }
1649
2008