@open-mercato/core 0.9.1-develop.7513.1.ae2715553f → 0.9.1-develop.7515.1.e2712c8b00

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.
Files changed (23) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js +4 -7
  3. package/dist/modules/shipping_carriers/api/webhook/[provider]/route.js.map +2 -2
  4. package/dist/modules/shipping_carriers/lib/shipping-service.js +29 -11
  5. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  6. package/dist/modules/shipping_carriers/lib/status-sync.js +2 -2
  7. package/dist/modules/shipping_carriers/lib/status-sync.js.map +2 -2
  8. package/dist/modules/shipping_carriers/workers/webhook-processor.js +60 -33
  9. package/dist/modules/shipping_carriers/workers/webhook-processor.js.map +3 -3
  10. package/dist/modules/warranty_claims/commands/claim-lines.js +89 -68
  11. package/dist/modules/warranty_claims/commands/claim-lines.js.map +2 -2
  12. package/dist/modules/warranty_claims/commands/claims.js +14 -13
  13. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  14. package/dist/modules/warranty_claims/commands/shared.js +8 -2
  15. package/dist/modules/warranty_claims/commands/shared.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/shipping_carriers/api/webhook/[provider]/route.ts +4 -7
  18. package/src/modules/shipping_carriers/lib/shipping-service.ts +29 -11
  19. package/src/modules/shipping_carriers/lib/status-sync.ts +2 -2
  20. package/src/modules/shipping_carriers/workers/webhook-processor.ts +71 -39
  21. package/src/modules/warranty_claims/commands/claim-lines.ts +107 -66
  22. package/src/modules/warranty_claims/commands/claims.ts +14 -13
  23. package/src/modules/warranty_claims/commands/shared.ts +7 -1
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "crypto";
2
- import { assertFound } from "@open-mercato/shared/lib/crud/errors";
2
+ import { CrudHttpError, assertFound } from "@open-mercato/shared/lib/crud/errors";
3
3
  import { invalidateCrudCache } from "@open-mercato/shared/lib/crud/cache";
4
4
  import { enforceCommandOptimisticLockWithGuards } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
5
5
  import { withAtomicFlush } from "@open-mercato/shared/lib/commands/flush";
6
6
  import { emitCrudSideEffects, flushCrudSideEffects } from "@open-mercato/shared/lib/commands/helpers";
7
7
  import { findOneWithDecryption, findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
8
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
8
9
  import {
9
10
  WarrantyClaim,
10
11
  WarrantyClaimEvent,
@@ -26,6 +27,10 @@ async function enforceWarrantyClaimOptimisticLock(ctx, record, resourceKind = WA
26
27
  request: ctx.request ?? null
27
28
  });
28
29
  }
30
+ async function throwUndoRefusal(key, fallback) {
31
+ const { translate } = await resolveTranslations();
32
+ throw new CrudHttpError(409, { error: translate(key, fallback), code: key });
33
+ }
29
34
  async function loadScopedClaim(em, id, scope, options = {}) {
30
35
  return findOneWithDecryption(
31
36
  em,
@@ -157,6 +162,7 @@ export {
157
162
  extractUndoPayload,
158
163
  loadScopedClaim,
159
164
  reconcileVendorRecoverySourceClaim,
160
- requireScopedClaim
165
+ requireScopedClaim,
166
+ throwUndoRefusal
161
167
  };
162
168
  //# sourceMappingURL=shared.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/warranty_claims/commands/shared.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { FindOneOptions } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { CrudHttpError, assertFound } from '@open-mercato/shared/lib/crud/errors'\nimport { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'\nimport { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport { emitCrudSideEffects, flushCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'\nimport type { DataEngine } from '@open-mercato/shared/lib/data/engine'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n WarrantyClaim,\n WarrantyClaimEvent,\n WarrantyClaimLine,\n} from '../data/entities'\nimport type {\n WarrantyClaimEventKind,\n WarrantyClaimEventVisibility,\n} from '../data/validators'\nimport { computeHeaderRollups } from '../lib/stateMachine'\n\nexport { assertFound } from '@open-mercato/shared/lib/crud/errors'\nexport { ensureOrganizationScope, ensureSameScope, ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'\nexport { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'\n\nexport const WARRANTY_CLAIM_RESOURCE_KIND = 'warranty_claims.claim'\nexport const WARRANTY_CLAIM_LINE_RESOURCE_KIND = 'warranty_claims.claim_line'\n\nexport type WarrantyClaimScope = {\n organizationId: string\n tenantId: string\n}\n\nexport type VersionedRecord = {\n id: string\n updatedAt?: Date | string | null\n}\n\nexport type ResolverContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\nexport type AppendClaimEventInput = {\n visibility?: WarrantyClaimEventVisibility\n body?: string | null\n payload?: Record<string, unknown> | null\n actorUserId?: string | null\n actorCustomerId?: string | null\n}\n\nexport async function enforceWarrantyClaimOptimisticLock(\n ctx: CommandRuntimeContext,\n record: VersionedRecord | null | undefined,\n resourceKind = WARRANTY_CLAIM_RESOURCE_KIND,\n expected?: string | Date | null,\n): Promise<void> {\n if (!record) return\n await enforceCommandOptimisticLockWithGuards(ctx.container, {\n resourceKind,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n expected,\n request: ctx.request ?? null,\n })\n}\n\nexport async function throwUndoRefusal(key: string, fallback: string): Promise<never> {\n const { translate } = await resolveTranslations()\n throw new CrudHttpError(409, { error: translate(key, fallback), code: key })\n}\n\nexport async function loadScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n): Promise<WarrantyClaim | null> {\n return findOneWithDecryption(\n em,\n WarrantyClaim,\n { id, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n options,\n scope,\n )\n}\n\n/**\n * `notFoundMessage` is passed to `assertFound` verbatim, so request-scoped callers\n * must hand in an already-translated sentence; the key default only suits the\n * command surface, which runs without a request locale.\n */\nexport async function requireScopedClaim(\n em: EntityManager,\n id: string,\n scope: WarrantyClaimScope,\n options: FindOneOptions<WarrantyClaim> = {},\n notFoundMessage = 'warranty_claims.errors.notFound',\n): Promise<WarrantyClaim> {\n const claim = await loadScopedClaim(em, id, scope, options)\n return assertFound(claim, notFoundMessage)\n}\n\nexport function appendClaimEvent(\n em: EntityManager,\n claim: WarrantyClaim,\n kind: WarrantyClaimEventKind,\n input: AppendClaimEventInput = {},\n): WarrantyClaimEvent {\n const event = em.create(WarrantyClaimEvent, {\n id: randomUUID(),\n claim,\n organizationId: claim.organizationId,\n tenantId: claim.tenantId,\n kind,\n visibility: input.visibility ?? 'internal',\n body: input.body ?? null,\n payload: input.payload ?? null,\n actorUserId: input.actorUserId ?? null,\n actorCustomerId: input.actorCustomerId ?? null,\n createdAt: new Date(),\n })\n em.persist(event)\n return event\n}\n\nexport async function reconcileVendorRecoverySourceClaim(\n ctx: ResolverContext,\n input: { claimId: string; tenantId: string; organizationId: string },\n): Promise<void> {\n const em = (ctx.resolve('em') as EntityManager).fork()\n const scope = { tenantId: input.tenantId, organizationId: input.organizationId }\n const recoveryClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: input.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!recoveryClaim || recoveryClaim.claimType !== 'vendor_recovery' || !recoveryClaim.sourceClaimId) return\n\n const sourceClaim = await findOneWithDecryption(\n em,\n WarrantyClaim,\n { id: recoveryClaim.sourceClaimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },\n {},\n scope,\n )\n if (!sourceClaim) return\n\n const resolvedChildren = await findWithDecryption(\n em,\n WarrantyClaim,\n {\n sourceClaimId: sourceClaim.id,\n claimType: 'vendor_recovery',\n status: { $in: ['resolved', 'closed'] },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n const childLines = resolvedChildren.length\n ? await findWithDecryption(\n em,\n WarrantyClaimLine,\n {\n claim: { $in: resolvedChildren.map((child) => child.id) },\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n {},\n scope,\n )\n : []\n const recoveredTotal = computeHeaderRollups(childLines).totalApprovedAmount\n await withAtomicFlush(\n em,\n [\n () => {\n sourceClaim.totalRecoveredAmount = String(recoveredTotal)\n sourceClaim.updatedAt = new Date()\n },\n ],\n { transaction: true, label: 'warranty_claims.vendor_recovery.reconciliation' },\n )\n\n const dataEngine = ctx.resolve<DataEngine>('dataEngine')\n await emitCrudSideEffects({\n dataEngine,\n action: 'updated',\n entity: sourceClaim,\n identifiers: {\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n },\n indexer: { entityType: 'warranty_claims:warranty_claim' },\n events: {\n module: 'warranty_claims',\n entity: 'claim',\n persistent: true,\n buildPayload: () => ({\n id: sourceClaim.id,\n organizationId: sourceClaim.organizationId,\n tenantId: sourceClaim.tenantId,\n claimType: sourceClaim.claimType,\n status: sourceClaim.status,\n }),\n },\n })\n await flushCrudSideEffects(dataEngine)\n await invalidateCrudCache(\n ctx as unknown as Parameters<typeof invalidateCrudCache>[0],\n 'warranty_claims.claim',\n { id: sourceClaim.id, organizationId: sourceClaim.organizationId, tenantId: sourceClaim.tenantId },\n input.tenantId,\n 'warranty_claims.vendor_recovery.reconciliation',\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["assertFound"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.9.1-develop.7513.1.ae2715553f",
3
+ "version": "0.9.1-develop.7515.1.e2712c8b00",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -264,16 +264,16 @@
264
264
  "zod": "^4.4.3"
265
265
  },
266
266
  "peerDependencies": {
267
- "@open-mercato/ai-assistant": "0.9.1-develop.7513.1.ae2715553f",
268
- "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
269
- "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
267
+ "@open-mercato/ai-assistant": "0.9.1-develop.7515.1.e2712c8b00",
268
+ "@open-mercato/shared": "0.9.1-develop.7515.1.e2712c8b00",
269
+ "@open-mercato/ui": "0.9.1-develop.7515.1.e2712c8b00",
270
270
  "react": "^19.0.0",
271
271
  "react-dom": "^19.0.0"
272
272
  },
273
273
  "devDependencies": {
274
- "@open-mercato/ai-assistant": "0.9.1-develop.7513.1.ae2715553f",
275
- "@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
276
- "@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
274
+ "@open-mercato/ai-assistant": "0.9.1-develop.7515.1.e2712c8b00",
275
+ "@open-mercato/shared": "0.9.1-develop.7515.1.e2712c8b00",
276
+ "@open-mercato/ui": "0.9.1-develop.7515.1.e2712c8b00",
277
277
  "@testing-library/dom": "^10.4.1",
278
278
  "@testing-library/jest-dom": "^7.0.1",
279
279
  "@testing-library/react": "^16.3.3",
@@ -115,13 +115,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ provide
115
115
  }
116
116
 
117
117
  await queue.enqueue({
118
- name: 'shipping-carrier-webhook',
119
- payload: {
120
- providerKey,
121
- event,
122
- shipmentId: shipment.id,
123
- scope: matchedScope,
124
- },
118
+ providerKey,
119
+ event,
120
+ shipmentId: shipment.id,
121
+ scope: matchedScope,
125
122
  })
126
123
 
127
124
  return NextResponse.json({ received: true, queued: true }, { status: 202 })
@@ -1,3 +1,4 @@
1
+ import { LockMode } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
4
  import type { CredentialsService } from '../../integrations/lib/credentials-service'
@@ -200,7 +201,7 @@ export function createShippingCarrierService(deps: {
200
201
  trackingNumber: created.trackingNumber,
201
202
  organizationId: input.organizationId,
202
203
  tenantId: input.tenantId,
203
- })
204
+ }, { tenantId: input.tenantId, organizationId: input.organizationId })
204
205
  return shipment
205
206
  } catch (error: unknown) {
206
207
  // Release the claim only when the carrier was NOT successfully called, so a retry can
@@ -256,24 +257,41 @@ export function createShippingCarrierService(deps: {
256
257
  trackingNumber: shipment.trackingNumber,
257
258
  credentials,
258
259
  })
259
- const previousStatus = shipment.unifiedStatus
260
- shipment.trackingEvents = tracking.events
261
- shipment.lastPolledAt = new Date()
262
- const transitionApplied = syncShipmentStatus(shipment, tracking.status)
263
- await em.flush()
264
- if (transitionApplied) {
260
+ const outcome = await em.transactional(async (tx) => {
261
+ const locked = await findOneWithDecryption(
262
+ tx,
263
+ CarrierShipment,
264
+ {
265
+ id: shipment.id,
266
+ organizationId: scope.organizationId,
267
+ tenantId: scope.tenantId,
268
+ deletedAt: null,
269
+ },
270
+ { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true },
271
+ scope,
272
+ )
273
+ if (!locked) return null
274
+ const previousStatus = locked.unifiedStatus
275
+ locked.trackingEvents = tracking.events
276
+ locked.lastPolledAt = new Date()
277
+ const applied = syncShipmentStatus(locked, tracking.status)
278
+ await tx.flush()
279
+ return { applied, previousStatus }
280
+ }, { clear: true })
281
+ if (outcome?.applied) {
265
282
  const eventPayload = {
266
283
  shipmentId: shipment.id,
267
284
  providerKey: input.providerKey,
268
- previousStatus,
285
+ previousStatus: outcome.previousStatus,
269
286
  newStatus: tracking.status,
270
287
  organizationId: input.organizationId,
271
288
  tenantId: input.tenantId,
272
289
  }
273
- await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload)
290
+ const eventScope = { tenantId: input.tenantId, organizationId: input.organizationId }
291
+ await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload, eventScope)
274
292
  if (TERMINAL_SHIPPING_STATUSES.has(tracking.status)) {
275
293
  const terminalEvent = getTerminalShippingEvent(tracking.status)
276
- if (terminalEvent) await emitShippingEvent(terminalEvent, eventPayload)
294
+ if (terminalEvent) await emitShippingEvent(terminalEvent, eventPayload, eventScope)
277
295
  }
278
296
  }
279
297
  return tracking
@@ -307,7 +325,7 @@ export function createShippingCarrierService(deps: {
307
325
  providerKey: input.providerKey,
308
326
  organizationId: input.organizationId,
309
327
  tenantId: input.tenantId,
310
- })
328
+ }, { tenantId: input.tenantId, organizationId: input.organizationId })
311
329
  return result
312
330
  },
313
331
 
@@ -29,8 +29,8 @@ export function isShipmentCancelNotAllowedError(error: unknown): error is Shipme
29
29
  }
30
30
 
31
31
  const VALID_SHIPPING_TRANSITIONS: Record<string, UnifiedShipmentStatus[]> = {
32
- label_created: ['picked_up', 'in_transit', 'cancelled'],
33
- picked_up: ['in_transit', 'cancelled'],
32
+ label_created: ['picked_up', 'in_transit', 'out_for_delivery', 'delivered', 'failed_delivery', 'returned', 'cancelled'],
33
+ picked_up: ['in_transit', 'out_for_delivery', 'delivered', 'failed_delivery', 'returned', 'cancelled'],
34
34
  in_transit: ['out_for_delivery', 'delivered', 'returned', 'failed_delivery'],
35
35
  out_for_delivery: ['delivered', 'returned', 'failed_delivery'],
36
36
  failed_delivery: ['in_transit', 'out_for_delivery', 'delivered', 'returned', 'cancelled'],
@@ -1,9 +1,10 @@
1
+ import { LockMode } from '@mikro-orm/core'
1
2
  import type { EntityManager } from '@mikro-orm/postgresql'
2
3
  import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue'
3
4
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
5
  import { CarrierShipment } from '../data/entities'
5
6
  import { emitShippingEvent } from '../events'
6
- import type { ShippingWebhookEvent } from '../lib/adapter'
7
+ import type { ShippingWebhookEvent, UnifiedShipmentStatus } from '../lib/adapter'
7
8
  import { getShippingAdapter } from '../lib/adapter-registry'
8
9
  import { getTerminalShippingEvent, syncShipmentStatus, TERMINAL_SHIPPING_STATUSES } from '../lib/status-sync'
9
10
  import { claimWebhookProcessing, releaseWebhookClaim } from '../lib/webhook-utils'
@@ -21,6 +22,16 @@ type WebhookJobPayload = {
21
22
  } | null
22
23
  }
23
24
 
25
+ type LegacyWebhookJobEnvelope = {
26
+ name?: string
27
+ payload: WebhookJobPayload
28
+ }
29
+
30
+ function readWebhookJobPayload(data: WebhookJobPayload | LegacyWebhookJobEnvelope): WebhookJobPayload {
31
+ if ('providerKey' in data) return data
32
+ return data.payload
33
+ }
34
+
24
35
  type HandlerContext = JobContext & {
25
36
  resolve: <T = unknown>(name: string) => T
26
37
  }
@@ -31,24 +42,25 @@ export const metadata: WorkerMeta = {
31
42
  concurrency: 5,
32
43
  }
33
44
 
34
- export default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: HandlerContext): Promise<void> {
45
+ export default async function handle(job: QueuedJob<WebhookJobPayload | LegacyWebhookJobEnvelope>, ctx: HandlerContext): Promise<void> {
46
+ const payload = readWebhookJobPayload(job.payload)
35
47
  try {
36
48
  const em = ctx.resolve<EntityManager>('em')
37
- const adapter = getShippingAdapter(job.payload.providerKey)
49
+ const adapter = getShippingAdapter(payload.providerKey)
38
50
  if (!adapter) return
39
51
 
40
- const shipment = job.payload.shipmentId && job.payload.scope
52
+ const shipment = payload.shipmentId && payload.scope
41
53
  ? await findOneWithDecryption(
42
54
  em,
43
55
  CarrierShipment,
44
56
  {
45
- id: job.payload.shipmentId,
46
- organizationId: job.payload.scope.organizationId,
47
- tenantId: job.payload.scope.tenantId,
57
+ id: payload.shipmentId,
58
+ organizationId: payload.scope.organizationId,
59
+ tenantId: payload.scope.tenantId,
48
60
  deletedAt: null,
49
61
  },
50
62
  undefined,
51
- job.payload.scope,
63
+ payload.scope,
52
64
  )
53
65
  : null
54
66
  if (!shipment) return
@@ -56,53 +68,73 @@ export default async function handle(job: QueuedJob<WebhookJobPayload>, ctx: Han
56
68
  const scope = { organizationId: shipment.organizationId, tenantId: shipment.tenantId }
57
69
  const claimed = await claimWebhookProcessing(
58
70
  em,
59
- job.payload.event.idempotencyKey,
60
- job.payload.providerKey,
71
+ payload.event.idempotencyKey,
72
+ payload.providerKey,
61
73
  scope,
62
- job.payload.event.eventType,
74
+ payload.event.eventType,
63
75
  )
64
76
  if (!claimed) {
65
77
  return
66
78
  }
67
79
 
80
+ const carrierStatus = typeof payload.event.data.status === 'string'
81
+ ? payload.event.data.status
82
+ : payload.event.eventType
83
+
84
+ let transition: { previousStatus: string; newStatus: UnifiedShipmentStatus } | null = null
68
85
  try {
69
- const carrierStatus = typeof job.payload.event.data.status === 'string'
70
- ? job.payload.event.data.status
71
- : job.payload.event.eventType
72
86
  const unifiedStatus = adapter.mapStatus(carrierStatus)
73
- shipment.carrierStatus = carrierStatus
74
- shipment.lastWebhookAt = new Date()
75
-
76
- const transitionApplied = syncShipmentStatus(shipment, unifiedStatus)
77
- if (!transitionApplied) return
78
-
79
- await em.flush()
80
-
81
- const eventPayload = {
82
- shipmentId: shipment.id,
83
- providerKey: job.payload.providerKey,
84
- previousStatus: carrierStatus,
85
- newStatus: unifiedStatus,
86
- organizationId: shipment.organizationId,
87
- tenantId: shipment.tenantId,
88
- }
89
- await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload)
90
- if (TERMINAL_SHIPPING_STATUSES.has(unifiedStatus)) {
91
- const terminalEvent = getTerminalShippingEvent(unifiedStatus)
92
- if (!terminalEvent) return
93
- await emitShippingEvent(terminalEvent, eventPayload)
94
- }
87
+ transition = await em.transactional(async (tx) => {
88
+ const locked = await findOneWithDecryption(
89
+ tx,
90
+ CarrierShipment,
91
+ {
92
+ id: shipment.id,
93
+ organizationId: scope.organizationId,
94
+ tenantId: scope.tenantId,
95
+ deletedAt: null,
96
+ },
97
+ { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true },
98
+ scope,
99
+ )
100
+ if (!locked) return null
101
+ const previousStatus = locked.unifiedStatus
102
+ if (!syncShipmentStatus(locked, unifiedStatus)) return null
103
+ locked.carrierStatus = carrierStatus
104
+ locked.lastWebhookAt = new Date()
105
+ await tx.flush()
106
+ return { previousStatus, newStatus: unifiedStatus }
107
+ }, { clear: true })
95
108
  } catch (error) {
96
109
  await releaseWebhookClaim(
97
110
  em,
98
- job.payload.event.idempotencyKey,
99
- job.payload.providerKey,
111
+ payload.event.idempotencyKey,
112
+ payload.providerKey,
100
113
  scope,
101
114
  )
102
115
  throw error
103
116
  }
117
+ if (!transition) return
118
+
119
+ const unifiedStatus = transition.newStatus
120
+ const eventPayload = {
121
+ shipmentId: shipment.id,
122
+ providerKey: payload.providerKey,
123
+ previousStatus: transition.previousStatus,
124
+ newStatus: unifiedStatus,
125
+ carrierStatus,
126
+ organizationId: shipment.organizationId,
127
+ tenantId: shipment.tenantId,
128
+ }
129
+ const eventScope = { tenantId: shipment.tenantId, organizationId: shipment.organizationId }
130
+ await emitShippingEvent('shipping_carriers.shipment.status_changed', eventPayload, eventScope)
131
+ if (TERMINAL_SHIPPING_STATUSES.has(unifiedStatus)) {
132
+ const terminalEvent = getTerminalShippingEvent(unifiedStatus)
133
+ if (!terminalEvent) return
134
+ await emitShippingEvent(terminalEvent, eventPayload, eventScope)
135
+ }
104
136
  } catch (error) {
105
- logger.error('Job processing failed', { providerKey: job.payload.providerKey, shipmentId: job.payload.shipmentId, err: error })
137
+ logger.error('Job processing failed', { providerKey: payload.providerKey, shipmentId: payload.shipmentId, err: error })
106
138
  throw error
107
139
  }
108
140
  }
@@ -43,6 +43,7 @@ import {
43
43
  ensureTenantScope,
44
44
  extractUndoPayload,
45
45
  requireScopedClaim,
46
+ throwUndoRefusal,
46
47
  type WarrantyClaimScope,
47
48
  } from './shared'
48
49
  import { assertPendingClaimQuantitiesWithinSold, validateClaimReferences } from './claims'
@@ -135,6 +136,31 @@ const mutableParentStatuses = new Set([
135
136
  'inspecting',
136
137
  ])
137
138
 
139
+ const receivingParentStatuses = new Set(['received', 'inspecting'])
140
+
141
+ type LineUndoParentRule = {
142
+ allowedParentStatuses: ReadonlySet<string>
143
+ lockedKey: string
144
+ lockedFallback: string
145
+ }
146
+
147
+ type LineUndoState = {
148
+ claim: WarrantyClaim
149
+ line: WarrantyClaimLine
150
+ }
151
+
152
+ const mutableParentUndoRule: LineUndoParentRule = {
153
+ allowedParentStatuses: mutableParentStatuses,
154
+ lockedKey: 'warranty_claims.errors.lineLocked',
155
+ lockedFallback: 'This line is locked in the current claim status.',
156
+ }
157
+
158
+ const receivingParentUndoRule: LineUndoParentRule = {
159
+ allowedParentStatuses: receivingParentStatuses,
160
+ lockedKey: 'warranty_claims.errors.receivingStatusInvalid',
161
+ lockedFallback: 'Receiving details can only be saved when the claim is received or under inspection.',
162
+ }
163
+
138
164
  function parseCommandInput<T>(schema: z.ZodType<T>, rawInput: unknown): T {
139
165
  const result = schema.safeParse(rawInput ?? {})
140
166
  if (!result.success) {
@@ -482,6 +508,37 @@ async function recomputeClaimRollups(em: EntityManager, claim: WarrantyClaim): P
482
508
  claim.updatedAt = new Date()
483
509
  }
484
510
 
511
+ function refuseLineUndoConflict(): Promise<never> {
512
+ return throwUndoRefusal('warranty_claims.errors.conflict', 'This claim changed while you were editing it.')
513
+ }
514
+
515
+ async function requireCurrentLineUndoState(
516
+ em: EntityManager,
517
+ after: LineSnapshot | null | undefined,
518
+ rule: LineUndoParentRule,
519
+ ): Promise<LineUndoState> {
520
+ if (!after) return refuseLineUndoConflict()
521
+ const scope: WarrantyClaimScope = { tenantId: after.tenantId, organizationId: after.organizationId }
522
+ const claim = await findOneWithDecryption(
523
+ em,
524
+ WarrantyClaim,
525
+ { id: after.claimId, tenantId: scope.tenantId, organizationId: scope.organizationId, deletedAt: null },
526
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
527
+ scope,
528
+ )
529
+ if (!claim) return refuseLineUndoConflict()
530
+ if (!rule.allowedParentStatuses.has(claim.status)) return throwUndoRefusal(rule.lockedKey, rule.lockedFallback)
531
+ const line = await findOneWithDecryption(
532
+ em,
533
+ WarrantyClaimLine,
534
+ { id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId },
535
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
536
+ scope,
537
+ )
538
+ if (!line || !after.updatedAt || toIso(line.updatedAt) !== after.updatedAt) return refuseLineUndoConflict()
539
+ return { claim, line }
540
+ }
541
+
485
542
  async function requireScopedLine(
486
543
  em: EntityManager,
487
544
  lineId: string,
@@ -654,22 +711,20 @@ const createClaimLineCommand: CommandHandler<ClaimLineCreateInput, { lineId: str
654
711
  buildLog: async ({ result, snapshots }) => buildLineLog('warranty_claims.audit.claim_line.create', result.lineId, snapshots),
655
712
  undo: async ({ logEntry, ctx }) => {
656
713
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
657
- const after = payload?.after
658
- if (!after) return
659
- const scope = { tenantId: after.tenantId, organizationId: after.organizationId }
660
714
  const em = (ctx.container.resolve('em') as EntityManager).fork()
661
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: after.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
662
- const claim = line ? claimOf(line) : null
663
- if (!line || !claim) return
715
+ let state!: LineUndoState
664
716
  await withAtomicFlush(em, [
717
+ async () => {
718
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
719
+ },
665
720
  () => {
666
- line.deletedAt = new Date()
667
- line.updatedAt = new Date()
721
+ state.line.deletedAt = new Date()
722
+ state.line.updatedAt = new Date()
668
723
  },
669
- () => recomputeClaimRollups(em, claim),
724
+ () => recomputeClaimRollups(em, state.claim),
670
725
  ], { transaction: true, label: 'warranty_claims.claim_line.create.undo' })
671
- await emitClaimUpdated(ctx, claim)
672
- await emitLineUndoCrud(ctx, 'deleted', line)
726
+ await emitClaimUpdated(ctx, state.claim)
727
+ await emitLineUndoCrud(ctx, 'deleted', state.line)
673
728
  },
674
729
  }
675
730
 
@@ -744,17 +799,17 @@ const updateClaimLineCommand: CommandHandler<ClaimLineUpdateInput, { lineId: str
744
799
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
745
800
  const before = payload?.before
746
801
  if (!before) return
747
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
748
802
  const em = (ctx.container.resolve('em') as EntityManager).fork()
749
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
750
- if (!line) return
751
- const claim = claimOf(line) ?? await requireScopedClaim(em, before.claimId, scope)
803
+ let state!: LineUndoState
752
804
  await withAtomicFlush(em, [
753
- () => restoreLineFromSnapshot(line, before),
754
- () => recomputeClaimRollups(em, claim),
805
+ async () => {
806
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
807
+ },
808
+ () => restoreLineFromSnapshot(state.line, before),
809
+ () => recomputeClaimRollups(em, state.claim),
755
810
  ], { transaction: true, label: 'warranty_claims.claim_line.update.undo' })
756
- await emitClaimUpdated(ctx, claim)
757
- await emitLineUndoCrud(ctx, 'updated', line)
811
+ await emitClaimUpdated(ctx, state.claim)
812
+ await emitLineUndoCrud(ctx, 'updated', state.line)
758
813
  },
759
814
  }
760
815
 
@@ -800,35 +855,20 @@ const deleteClaimLineCommand: CommandHandler<LineDeleteInput, { lineId: string;
800
855
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
801
856
  const before = payload?.before
802
857
  if (!before) return
803
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
804
858
  const em = (ctx.container.resolve('em') as EntityManager).fork()
805
- let line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
806
- const claim = (line ? claimOf(line) : null) ?? await requireScopedClaim(em, before.claimId, scope)
859
+ let state!: LineUndoState
807
860
  await withAtomicFlush(em, [
861
+ async () => {
862
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
863
+ },
808
864
  () => {
809
- if (!line) {
810
- line = em.create(WarrantyClaimLine, {
811
- id: before.id,
812
- claim,
813
- organizationId: before.organizationId,
814
- tenantId: before.tenantId,
815
- lineNo: before.lineNo,
816
- qtyClaimed: before.qtyClaimed,
817
- lineStatus: before.lineStatus,
818
- warrantyStatus: before.warrantyStatus,
819
- createdAt: new Date(),
820
- updatedAt: new Date(),
821
- })
822
- em.persist(line)
823
- }
824
- restoreLineFromSnapshot(line, before)
825
- line.deletedAt = null
865
+ restoreLineFromSnapshot(state.line, before)
866
+ state.line.deletedAt = null
826
867
  },
827
- () => recomputeClaimRollups(em, claim),
868
+ () => recomputeClaimRollups(em, state.claim),
828
869
  ], { transaction: true, label: 'warranty_claims.claim_line.delete.undo' })
829
- if (!line) return
830
- await emitClaimUpdated(ctx, claim)
831
- await emitLineUndoCrud(ctx, 'created', line)
870
+ await emitClaimUpdated(ctx, state.claim)
871
+ await emitLineUndoCrud(ctx, 'created', state.line)
832
872
  },
833
873
  }
834
874
 
@@ -916,17 +956,17 @@ const receiveClaimLineCommand: CommandHandler<ClaimLineReceiveInput, { lineId: s
916
956
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
917
957
  const before = payload?.before
918
958
  if (!before) return
919
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
920
959
  const em = (ctx.container.resolve('em') as EntityManager).fork()
921
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
922
- if (!line) return
923
- const claim = await requireScopedClaim(em, before.claimId, scope)
960
+ let state!: LineUndoState
924
961
  await withAtomicFlush(em, [
925
- () => restoreLineFromSnapshot(line, before),
926
- () => recomputeClaimRollups(em, claim),
962
+ async () => {
963
+ state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
964
+ },
965
+ () => restoreLineFromSnapshot(state.line, before),
966
+ () => recomputeClaimRollups(em, state.claim),
927
967
  ], { transaction: true, label: 'warranty_claims.claim_line.receive.undo' })
928
- await emitClaimUpdated(ctx, claim)
929
- await emitLineUndoCrud(ctx, 'updated', line)
968
+ await emitClaimUpdated(ctx, state.claim)
969
+ await emitLineUndoCrud(ctx, 'updated', state.line)
930
970
  },
931
971
  }
932
972
 
@@ -980,17 +1020,17 @@ const releaseClaimLineQuarantineCommand: CommandHandler<ClaimLineReleaseQuaranti
980
1020
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
981
1021
  const before = payload?.before
982
1022
  if (!before) return
983
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
984
1023
  const em = (ctx.container.resolve('em') as EntityManager).fork()
985
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
986
- if (!line) return
987
- const claim = await requireScopedClaim(em, before.claimId, scope)
1024
+ let state!: LineUndoState
988
1025
  await withAtomicFlush(em, [
989
- () => restoreLineFromSnapshot(line, before),
990
- () => recomputeClaimRollups(em, claim),
1026
+ async () => {
1027
+ state = await requireCurrentLineUndoState(em, payload?.after, receivingParentUndoRule)
1028
+ },
1029
+ () => restoreLineFromSnapshot(state.line, before),
1030
+ () => recomputeClaimRollups(em, state.claim),
991
1031
  ], { transaction: true, label: 'warranty_claims.claim_line.release_quarantine.undo' })
992
- await emitClaimUpdated(ctx, claim)
993
- await emitLineUndoCrud(ctx, 'updated', line)
1032
+ await emitClaimUpdated(ctx, state.claim)
1033
+ await emitLineUndoCrud(ctx, 'updated', state.line)
994
1034
  },
995
1035
  }
996
1036
 
@@ -1043,16 +1083,17 @@ const setClaimLineAssessmentCommand: CommandHandler<LineSetAssessmentInput, { li
1043
1083
  const payload = extractUndoPayload<LineUndoPayload>(logEntry)
1044
1084
  const before = payload?.before
1045
1085
  if (!before) return
1046
- const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
1047
1086
  const em = (ctx.container.resolve('em') as EntityManager).fork()
1048
- const line = await findOneWithDecryption(em, WarrantyClaimLine, { id: before.id, tenantId: scope.tenantId, organizationId: scope.organizationId }, { populate: ['claim'] }, scope)
1049
- if (!line) return
1050
- const claim = claimOf(line) ?? await requireScopedClaim(em, before.claimId, scope)
1087
+ let state!: LineUndoState
1051
1088
  await withAtomicFlush(em, [
1052
- () => restoreLineFromSnapshot(line, before),
1089
+ async () => {
1090
+ state = await requireCurrentLineUndoState(em, payload?.after, mutableParentUndoRule)
1091
+ },
1092
+ () => restoreLineFromSnapshot(state.line, before),
1093
+ () => recomputeClaimRollups(em, state.claim),
1053
1094
  ], { transaction: true, label: 'warranty_claims.claim_line.set_assessment.undo' })
1054
- await emitClaimUpdated(ctx, claim)
1055
- await emitLineUndoCrud(ctx, 'updated', line)
1095
+ await emitClaimUpdated(ctx, state.claim)
1096
+ await emitLineUndoCrud(ctx, 'updated', state.line)
1056
1097
  },
1057
1098
  }
1058
1099