@tokenoftrust/storefront-runner 6.5.0 → 6.5.2
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/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
- package/apps/storefront/migrations-apps/0027_app_contact_authority.sql +70 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
- package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
- package/apps/storefront/src/lib/integrations/apps/appContactAuthority.ts +27 -0
- package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +101 -65
- package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
- package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
- package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
- package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
- package/apps/storefront/src/lib/orders/vault/appRecipientRead.ts +217 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
- package/package.json +1 -1
|
@@ -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
|
-
| {
|
|
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 {
|
|
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: {
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|