@open-mercato/core 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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 (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/dbFixtures.js.map +2 -2
  3. package/dist/helpers/integration/ui.js +3 -0
  4. package/dist/helpers/integration/ui.js.map +2 -2
  5. package/dist/modules/auth/lib/setup-app.js +3 -2
  6. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  7. package/dist/modules/catalog/widgets/injection-table.js +5 -4
  8. package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
  9. package/dist/modules/currencies/di.js +2 -0
  10. package/dist/modules/currencies/di.js.map +2 -2
  11. package/dist/modules/currencies/services/providers/registry.js +31 -0
  12. package/dist/modules/currencies/services/providers/registry.js.map +7 -0
  13. package/dist/modules/directory/api/organization-switcher/route.js +7 -4
  14. package/dist/modules/directory/api/organization-switcher/route.js.map +2 -2
  15. package/dist/modules/entities/api/encryption.js +22 -11
  16. package/dist/modules/entities/api/encryption.js.map +2 -2
  17. package/dist/modules/notifications/api/openapi.js +5 -0
  18. package/dist/modules/notifications/api/openapi.js.map +2 -2
  19. package/dist/modules/notifications/api/route.js +27 -4
  20. package/dist/modules/notifications/api/route.js.map +2 -2
  21. package/dist/modules/notifications/api/settings/route.js +2 -1
  22. package/dist/modules/notifications/api/settings/route.js.map +2 -2
  23. package/dist/modules/notifications/api/unread-count/route.js +4 -2
  24. package/dist/modules/notifications/api/unread-count/route.js.map +2 -2
  25. package/dist/modules/notifications/lib/routeHelpers.js +25 -0
  26. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  27. package/dist/modules/payment_gateways/lib/descriptor-service.js +12 -1
  28. package/dist/modules/payment_gateways/lib/descriptor-service.js.map +2 -2
  29. package/dist/modules/progress/data/validators.js +2 -1
  30. package/dist/modules/progress/data/validators.js.map +2 -2
  31. package/dist/modules/progress/lib/progressServiceImpl.js +4 -0
  32. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  33. package/dist/modules/sales/commands/payments.js +6 -4
  34. package/dist/modules/sales/commands/payments.js.map +2 -2
  35. package/dist/modules/sales/widgets/injection-table.js +7 -4
  36. package/dist/modules/sales/widgets/injection-table.js.map +2 -2
  37. package/package.json +7 -7
  38. package/src/helpers/integration/dbFixtures.ts +20 -2
  39. package/src/helpers/integration/ui.ts +12 -0
  40. package/src/modules/auth/lib/setup-app.ts +8 -2
  41. package/src/modules/catalog/widgets/injection-table.ts +5 -4
  42. package/src/modules/currencies/di.ts +2 -0
  43. package/src/modules/currencies/services/providers/registry.ts +35 -0
  44. package/src/modules/directory/api/organization-switcher/route.ts +16 -4
  45. package/src/modules/entities/api/encryption.ts +22 -12
  46. package/src/modules/notifications/api/openapi.ts +5 -0
  47. package/src/modules/notifications/api/route.ts +31 -2
  48. package/src/modules/notifications/api/settings/route.ts +8 -1
  49. package/src/modules/notifications/api/unread-count/route.ts +4 -2
  50. package/src/modules/notifications/lib/routeHelpers.ts +60 -0
  51. package/src/modules/payment_gateways/lib/descriptor-service.ts +12 -0
  52. package/src/modules/progress/data/validators.ts +1 -0
  53. package/src/modules/progress/lib/progressServiceImpl.ts +4 -0
  54. package/src/modules/sales/commands/payments.ts +8 -4
  55. package/src/modules/sales/widgets/injection-table.ts +7 -4
@@ -0,0 +1,35 @@
1
+ import type { RateProvider } from './base'
2
+
3
+ const CURRENCY_RATE_PROVIDER_REGISTRY_KEY = Symbol.for('@open-mercato/currencies/rate-provider-registry')
4
+
5
+ type GlobalWithCurrencyRateProviderRegistry = typeof globalThis & {
6
+ [CURRENCY_RATE_PROVIDER_REGISTRY_KEY]?: Map<string, RateProvider>
7
+ }
8
+
9
+ function getProviderRegistry(): Map<string, RateProvider> {
10
+ const globalScope = globalThis as GlobalWithCurrencyRateProviderRegistry
11
+ if (!globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY]) {
12
+ globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY] = new Map<string, RateProvider>()
13
+ }
14
+ return globalScope[CURRENCY_RATE_PROVIDER_REGISTRY_KEY]
15
+ }
16
+
17
+ export function registerCurrencyRateProvider(provider: RateProvider): () => void {
18
+ const providerRegistry = getProviderRegistry()
19
+ providerRegistry.set(provider.source, provider)
20
+ return () => {
21
+ if (providerRegistry.get(provider.source) === provider) providerRegistry.delete(provider.source)
22
+ }
23
+ }
24
+
25
+ export function getCurrencyRateProvider(source: string): RateProvider | undefined {
26
+ return getProviderRegistry().get(source)
27
+ }
28
+
29
+ export function listCurrencyRateProviders(): RateProvider[] {
30
+ return Array.from(getProviderRegistry().values())
31
+ }
32
+
33
+ export function clearCurrencyRateProviders(): void {
34
+ getProviderRegistry().clear()
35
+ }
@@ -129,7 +129,19 @@ export async function GET(req: NextRequest) {
129
129
 
130
130
  const rawTenantParam = url.searchParams.get('tenantId')
131
131
  const cookieTenant = getSelectedTenantFromRequest(req)
132
- const actorTenantId = typeof auth.tenantId === 'string' && auth.tenantId.trim().length > 0 ? auth.tenantId.trim() : null
132
+ const normalizeTenantId = (value: unknown): string | null =>
133
+ typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
134
+
135
+ const authTenantId = normalizeTenantId(auth.tenantId)
136
+ // `auth.tenantId` is the tenant the session is currently scoped to, which a super-admin's
137
+ // cookie override replaces. The tenant they actually belong to survives under `actorTenantId`,
138
+ // so the "no selection" fallback below has to read that field — mirroring `organizationScope`.
139
+ // Reading `auth.tenantId` instead landed a scoped-away super-admin on `tenantRecords[0]`, the
140
+ // alphabetically-first tenant in the instance, rather than their own.
141
+ const actorTenantField = (auth as { actorTenantId?: string | null }).actorTenantId
142
+ const actorHomeTenantId = actorTenantField === undefined
143
+ ? authTenantId
144
+ : normalizeTenantId(actorTenantField)
133
145
  const actorIsSuperAdmin = auth.isSuperAdmin === true
134
146
 
135
147
  let requestedTenantId = rawTenantParam ?? (cookieTenant ?? undefined)
@@ -144,12 +156,12 @@ export async function GET(req: NextRequest) {
144
156
  name: typeof tenant.name === 'string' && tenant.name.length > 0 ? tenant.name : String(tenant.id),
145
157
  isActive: tenant.isActive !== false,
146
158
  }))
147
- if (!tenantId) tenantId = actorTenantId ?? (tenantRecords[0]?.id ?? null)
159
+ if (!tenantId) tenantId = actorHomeTenantId ?? (tenantRecords[0]?.id ?? null)
148
160
  if (tenantId && tenantRecords.length && !tenantRecords.some((record) => record.id === tenantId)) {
149
161
  tenantId = tenantRecords[0]?.id ?? tenantId
150
162
  }
151
163
  } else {
152
- tenantId = actorTenantId
164
+ tenantId = authTenantId
153
165
  }
154
166
 
155
167
  if (!tenantId) {
@@ -206,7 +218,7 @@ export async function GET(req: NextRequest) {
206
218
  }
207
219
  }
208
220
 
209
- const scopedOrgId = actorTenantId && actorTenantId === tenantId ? auth.orgId ?? null : null
221
+ const scopedOrgId = authTenantId && authTenantId === tenantId ? auth.orgId ?? null : null
210
222
  const acl = await rbac.loadAcl(auth.sub, { tenantId, organizationId: scopedOrgId })
211
223
  const aclIsSuperAdmin = acl?.isSuperAdmin === true
212
224
  const effectiveIsSuperAdmin = actorIsSuperAdmin || aclIsSuperAdmin
@@ -11,6 +11,7 @@ import {
11
11
  validateCrudMutationGuard,
12
12
  } from '@open-mercato/shared/lib/crud/mutation-guard'
13
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
14
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
14
15
 
15
16
  const ENCRYPTION_MAP_RESOURCE_KIND = 'entities.encryption_map'
16
17
 
@@ -19,13 +20,6 @@ export const metadata = {
19
20
  POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
20
21
  }
21
22
 
22
- function resolveScope(auth: { tenantId?: string | null; orgId?: string | null }) {
23
- return {
24
- tenantId: auth.tenantId ?? null,
25
- organizationId: auth.orgId ?? null,
26
- }
27
- }
28
-
29
23
  function toIsoOrNull(value: Date | string | null | undefined): string | null {
30
24
  if (value == null) return null
31
25
  if (value instanceof Date) {
@@ -42,9 +36,11 @@ export async function GET(req: Request) {
42
36
  if (!entityId) return NextResponse.json({ error: 'entityId is required' }, { status: 400 })
43
37
  const auth = await getAuthFromRequest(req)
44
38
  if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
45
- const { tenantId, organizationId } = resolveScope(auth)
46
39
 
47
40
  const container = await createRequestContainer()
41
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
42
+ const tenantId = scope.tenantId ?? auth.tenantId
43
+ const organizationId = scope.selectedId
48
44
  const em = container.resolve('em') as any
49
45
  const repo = em.getRepository(EncryptionMap)
50
46
  // Prefer tenant+org, then tenant-global, then global
@@ -55,7 +51,6 @@ export async function GET(req: Request) {
55
51
  ]
56
52
  let record: any = null
57
53
  for (const where of candidates) {
58
- // eslint-disable-next-line no-await-in-loop
59
54
  const found = await repo.findOne({ ...where, deletedAt: null })
60
55
  if (found) {
61
56
  record = found
@@ -82,12 +77,21 @@ export async function POST(req: Request) {
82
77
  }
83
78
  const auth = await getAuthFromRequest(req)
84
79
  if (!auth?.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
85
- const scope = resolveScope(auth)
86
80
  const payload = parsed.data
87
- const tenantId: string = auth.tenantId
88
- const organizationId = scope.organizationId
89
81
 
90
82
  const container = await createRequestContainer()
83
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
84
+ if (scope.selectionRejected) {
85
+ return NextResponse.json(
86
+ {
87
+ error: 'Your selected organization is no longer available. Please re-select an organization and try again.',
88
+ code: 'organization_selection_invalid',
89
+ },
90
+ { status: 422 },
91
+ )
92
+ }
93
+ const tenantId = scope.tenantId ?? auth.tenantId
94
+ const organizationId = scope.selectedId
91
95
  const em = container.resolve('em') as any
92
96
  const repo = em.getRepository(EncryptionMap)
93
97
  const existing = await repo.findOne({ entityId: payload.entityId, tenantId, organizationId, deletedAt: null })
@@ -177,6 +181,11 @@ const conflictResponseSchema = z.object({
177
181
  expectedUpdatedAt: z.string(),
178
182
  })
179
183
 
184
+ const organizationSelectionInvalidResponseSchema = z.object({
185
+ error: z.string(),
186
+ code: z.literal('organization_selection_invalid'),
187
+ })
188
+
180
189
  export const openApi: OpenApiRouteDoc = {
181
190
  tag: 'Entities',
182
191
  summary: 'Manage encryption maps',
@@ -194,6 +203,7 @@ export const openApi: OpenApiRouteDoc = {
194
203
  responses: [
195
204
  { status: 200, description: 'Saved', schema: z.object({ ok: z.boolean(), updatedAt: z.string().nullable().optional() }) },
196
205
  { status: 409, description: 'Optimistic-lock conflict (stale write)', schema: conflictResponseSchema },
206
+ { status: 422, description: 'Selected organization is unavailable', schema: organizationSelectionInvalidResponseSchema },
197
207
  ],
198
208
  },
199
209
  },
@@ -48,6 +48,11 @@ export const errorResponseSchema = z.object({
48
48
  error: z.string(),
49
49
  })
50
50
 
51
+ export const scopeErrorResponseSchema = z.object({
52
+ error: z.string(),
53
+ code: z.string(),
54
+ })
55
+
51
56
  export const unreadCountResponseSchema = z.object({
52
57
  unreadCount: z.number(),
53
58
  })
@@ -8,6 +8,7 @@ import {
8
8
  isCrudCacheEnabled,
9
9
  resolveCrudCache,
10
10
  } from '@open-mercato/shared/lib/crud/cache'
11
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'
11
12
  import { Notification } from '../data/entities'
12
13
  import { listNotificationsSchema, createNotificationSchema } from '../data/validators'
13
14
  import { toNotificationDto } from '../lib/notificationMapper'
@@ -19,13 +20,16 @@ import {
19
20
  NOTIFICATION_RESOURCE_KIND,
20
21
  notificationCrudErrorResponse,
21
22
  notificationValidationErrorResponse,
23
+ resolveGuardedNotificationContext,
22
24
  resolveNotificationContext,
23
25
  runGuardedNotificationWrite,
26
+ TENANT_SCOPE_REQUIRED_ERROR_CODE,
24
27
  } from '../lib/routeHelpers'
25
28
  import {
26
29
  buildNotificationsCrudOpenApi,
27
30
  createPagedListResponseSchema,
28
31
  notificationItemSchema,
32
+ scopeErrorResponseSchema,
29
33
  } from './openapi'
30
34
 
31
35
  export const metadata = {
@@ -89,7 +93,9 @@ function isNotificationsListPayload(value: unknown): value is NotificationsListP
89
93
  }
90
94
 
91
95
  export async function GET(req: Request) {
92
- const { ctx, scope } = await resolveNotificationContext(req)
96
+ const resolved = await resolveGuardedNotificationContext(req)
97
+ if (!resolved.ok) return resolved.response
98
+ const { ctx, scope } = resolved
93
99
  const em = ctx.container.resolve('em') as EntityManager
94
100
 
95
101
  const url = new URL(req.url)
@@ -226,7 +232,7 @@ export async function POST(req: Request) {
226
232
  }
227
233
  }
228
234
 
229
- export const openApi = buildNotificationsCrudOpenApi({
235
+ const notificationsCrudOpenApi = buildNotificationsCrudOpenApi({
230
236
  resourceName: 'Notification',
231
237
  querySchema: listNotificationsSchema,
232
238
  listResponseSchema: createPagedListResponseSchema(notificationItemSchema),
@@ -236,3 +242,26 @@ export const openApi = buildNotificationsCrudOpenApi({
236
242
  description: 'Creates a notification for a user.',
237
243
  },
238
244
  })
245
+
246
+ const notificationsCrudGet = notificationsCrudOpenApi.methods?.GET ?? {}
247
+
248
+ // The CRUD factory documents errors only for DELETE, and POST already gets an auto-generated 403
249
+ // from its `requireFeatures` metadata. GET is authenticated-only, so its tenant-scope rejection has
250
+ // to be declared here to reach the generated spec.
251
+ export const openApi: OpenApiRouteDoc = {
252
+ ...notificationsCrudOpenApi,
253
+ methods: {
254
+ ...notificationsCrudOpenApi.methods,
255
+ GET: {
256
+ ...notificationsCrudGet,
257
+ errors: [
258
+ ...(notificationsCrudGet.errors ?? []),
259
+ {
260
+ status: 403,
261
+ description: `Request could not be resolved to a tenant scope (code: ${TENANT_SCOPE_REQUIRED_ERROR_CODE})`,
262
+ schema: scopeErrorResponseSchema,
263
+ },
264
+ ],
265
+ },
266
+ },
267
+ }
@@ -69,12 +69,19 @@ export async function POST(req: Request) {
69
69
  )
70
70
  }
71
71
 
72
+ // Unlike the other write routes this one does not go through `resolveNotificationContext`, so it
73
+ // never sees the organization scope's `actorTenantId` fallback. A super-admin scoped away from
74
+ // their own tenant has `auth.tenantId === null` with the real tenant preserved in `actorTenantId`,
75
+ // and delivery settings are instance-global anyway — reading it here keeps that caller working
76
+ // while a genuinely tenant-less principal still fails the guard below.
77
+ const actorTenantId = (auth as { actorTenantId?: string | null }).actorTenantId ?? null
78
+
72
79
  const container = await createRequestContainer()
73
80
  try {
74
81
  const guarded = await runGuardedNotificationWrite(
75
82
  container,
76
83
  {
77
- tenantId: auth.tenantId ?? '',
84
+ tenantId: auth.tenantId ?? actorTenantId ?? '',
78
85
  organizationId: auth.orgId ?? null,
79
86
  userId: auth.sub ?? null,
80
87
  },
@@ -8,7 +8,7 @@ import {
8
8
  } from '@open-mercato/shared/lib/crud/cache'
9
9
  import { Notification } from '../../data/entities'
10
10
  import { unreadCountResponseSchema } from '../openapi'
11
- import { resolveNotificationContext } from '../../lib/routeHelpers'
11
+ import { resolveGuardedNotificationContext } from '../../lib/routeHelpers'
12
12
  import {
13
13
  buildNotificationReadScopeWhere,
14
14
  getNotificationReadScopeTagOrganizationIds,
@@ -39,7 +39,9 @@ function buildUnreadCountCacheKey(params: {
39
39
  }
40
40
 
41
41
  export async function GET(req: Request) {
42
- const { scope, ctx } = await resolveNotificationContext(req)
42
+ const resolved = await resolveGuardedNotificationContext(req)
43
+ if (!resolved.ok) return resolved.response
44
+ const { scope, ctx } = resolved
43
45
  const em = ctx.container.resolve('em') as EntityManager
44
46
 
45
47
  const userId = scope.userId
@@ -60,6 +60,42 @@ export function notificationCrudErrorResponse(error: unknown): Response | null {
60
60
  return Response.json(error.body ?? { error: 'Notification request failed' }, { status: error.status })
61
61
  }
62
62
 
63
+ /**
64
+ * Machine-readable discriminator for the tenant-scope rejection, mirroring
65
+ * `ORGANIZATION_SCOPE_REQUIRED_ERROR_CODE`. Without it a client cannot tell an unresolved scope
66
+ * apart from an ordinary permission denial, since both are a 403 carrying only a message.
67
+ */
68
+ export const TENANT_SCOPE_REQUIRED_ERROR_CODE = 'tenant_scope_required'
69
+
70
+ /**
71
+ * Fail closed when a request cannot be resolved to a tenant.
72
+ *
73
+ * `resolveNotificationContext` falls back to `''` when neither the organization scope nor the auth
74
+ * context yields a tenant — reachable for a genuinely tenant-less principal such as an unscoped
75
+ * super-admin API key. `notifications.tenant_id` is a NOT NULL uuid, so that `''` makes the driver
76
+ * reject every read and write built from the scope; dropping the tenant predicate instead would
77
+ * leave `recipientUserId` as the only thing keeping a read inside one tenant.
78
+ *
79
+ * Unlike the audit-log read guard there is no `isSuperAdmin` escape hatch: notification rows are
80
+ * per-recipient and per-tenant, so there is no cross-tenant read mode to preserve.
81
+ *
82
+ * Plain truthiness is deliberate — it matches the `?? ''` sentinel and also rejects a null or
83
+ * omitted tenant reaching this helper from a caller that builds its own scope.
84
+ */
85
+ export async function requireResolvedNotificationTenantScope(
86
+ scope: { tenantId?: string | null },
87
+ ): Promise<Response | null> {
88
+ if (scope.tenantId) return null
89
+ const { t } = await resolveTranslations()
90
+ return Response.json(
91
+ {
92
+ error: t('api.errors.forbidden', 'Forbidden'),
93
+ code: TENANT_SCOPE_REQUIRED_ERROR_CODE,
94
+ },
95
+ { status: 403 },
96
+ )
97
+ }
98
+
63
99
  /**
64
100
  * Resolve notification service and scope from a request.
65
101
  * Centralizes the common pattern used across all notification API routes.
@@ -92,6 +128,25 @@ export async function resolveNotificationContext(req: Request): Promise<Notifica
92
128
  }
93
129
  }
94
130
 
131
+ export type GuardedNotificationContext =
132
+ | ({ ok: true } & NotificationRequestContext)
133
+ | { ok: false; response: Response }
134
+
135
+ /**
136
+ * Resolve the notification context and reject the request when it has no tenant.
137
+ *
138
+ * Writes are structurally safe because every one of them funnels through
139
+ * `runGuardedNotificationWrite`. Reads have no such choke point, so they resolve their context
140
+ * through this wrapper instead: a route that forgets to check cannot compile past the discriminated
141
+ * result, which makes the guard impossible to skip by omission rather than by convention.
142
+ */
143
+ export async function resolveGuardedNotificationContext(req: Request): Promise<GuardedNotificationContext> {
144
+ const resolved = await resolveNotificationContext(req)
145
+ const tenantScopeGuard = await requireResolvedNotificationTenantScope(resolved.scope)
146
+ if (tenantScopeGuard) return { ok: false, response: tenantScopeGuard }
147
+ return { ok: true, ...resolved }
148
+ }
149
+
95
150
  /**
96
151
  * Mutation-guard options for a notification write.
97
152
  */
@@ -120,6 +175,11 @@ export async function runGuardedNotificationWrite<T>(
120
175
  options: NotificationMutationGuardOptions,
121
176
  write: () => Promise<T>,
122
177
  ): Promise<GuardedNotificationWriteResult<T>> {
178
+ const tenantScopeGuard = await requireResolvedNotificationTenantScope(scope)
179
+ if (tenantScopeGuard) {
180
+ return { ok: false, response: tenantScopeGuard }
181
+ }
182
+
123
183
  const guarded = await runRouteMutationGuards({
124
184
  container,
125
185
  req,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getAllIntegrations,
3
+ resolveIntegrationCredentialsSchema,
3
4
  type IntegrationScope,
4
5
  } from '@open-mercato/shared/modules/integrations/types'
5
6
  import {
@@ -52,6 +53,17 @@ async function resolveDescriptor(
52
53
  }
53
54
  }
54
55
 
56
+ const credentialsSchema = resolveIntegrationCredentialsSchema(integration.id)
57
+ if (credentialsSchema?.fields.length === 0) {
58
+ return {
59
+ ...descriptor,
60
+ integrationId: integration.id,
61
+ requiresConfiguration: false,
62
+ isConfigured: true,
63
+ configurationStatus: 'unmanaged',
64
+ }
65
+ }
66
+
55
67
  const [credentials, state] = await Promise.all([
56
68
  deps.integrationCredentialsService.resolve(integration.id, scope),
57
69
  deps.integrationStateService.resolveState(integration.id, scope),
@@ -29,6 +29,7 @@ export const completeJobSchema = z.object({
29
29
  export const failJobSchema = z.object({
30
30
  errorMessage: z.string().max(2000),
31
31
  errorStack: z.string().max(10000).optional(),
32
+ resultSummary: z.record(z.string(), z.unknown()).optional(),
32
33
  })
33
34
 
34
35
  export const listProgressJobsSchema = z.object({
@@ -387,6 +387,7 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
387
387
  finishedAt: now,
388
388
  errorMessage: input.errorMessage,
389
389
  errorStack: input.errorStack,
390
+ ...(input.resultSummary ? { resultSummary: input.resultSummary } : {}),
390
391
  updatedAt: now,
391
392
  ...(entry
392
393
  ? {
@@ -412,6 +413,9 @@ export function createProgressService(em: EntityManager, eventBus: { emit: (even
412
413
  snapshot.finishedAt = now
413
414
  snapshot.errorMessage = input.errorMessage
414
415
  snapshot.errorStack = input.errorStack
416
+ if (input.resultSummary) {
417
+ snapshot.resultSummary = input.resultSummary
418
+ }
415
419
 
416
420
  const persistedJob = (await loadFreshJob(jobId, ctx)) ?? snapshot
417
421
 
@@ -539,10 +539,14 @@ const createPaymentCommand: CommandHandler<
539
539
  linkHref: `/backend/sales/orders/${order.id}`,
540
540
  })
541
541
 
542
- await notificationService.createForFeature(notificationInput, {
543
- tenantId: payment.tenantId,
544
- organizationId: payment.organizationId ?? null,
545
- })
542
+ // Bulk-import backfills opt out of the per-record notification fan-out (and its inline
543
+ // e-mail delivery); interactive creates are unaffected.
544
+ if (!ctx.bulkImport?.skipNotifications) {
545
+ await notificationService.createForFeature(notificationInput, {
546
+ tenantId: payment.tenantId,
547
+ organizationId: payment.organizationId ?? null,
548
+ })
549
+ }
546
550
  }
547
551
  } catch (err) {
548
552
  // Notification creation is non-critical, don't fail the command
@@ -17,10 +17,13 @@ export const injectionTable: ModuleInjectionTable = {
17
17
  priority: 50,
18
18
  },
19
19
  ],
20
- 'data-table:sales.payments:columns': {
21
- widgetId: 'sales.injection.payment-gateway-status-column',
22
- priority: 50,
23
- },
20
+ // NOTE: the 'data-table:sales.payments:columns' binding for
21
+ // 'sales.injection.payment-gateway-status-column' was removed here — it could never
22
+ // resolve. PaymentsSection.tsx renders its DataTable with no perspective/injectionSpotId/
23
+ // extensionTableId, so extensionTableId is null and the columns spot degrades to
24
+ // '__disabled__:columns'. The widget itself is still registered and is now UNBOUND: giving
25
+ // the payments table a real tableId so the gateway-status column finally renders is a sales
26
+ // feature gap, tracked in #5142 rather than silently reintroduced here.
24
27
  'crud-form:sales.payment_method:fields': {
25
28
  widgetId: 'sales.injection.payment-gateway-config-field',
26
29
  priority: 40,