@open-mercato/carrier-inpost 0.10.1-develop.8062.1.f29b9ae445 → 0.10.1-develop.8075.1.f2d38008c0

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.
@@ -1,2 +1,2 @@
1
- [build:carrier-inpost] found 25 entry points
1
+ [build:carrier-inpost] found 26 entry points
2
2
  [build:carrier-inpost] built successfully
@@ -0,0 +1,117 @@
1
+ import { createHmac, randomUUID } from "node:crypto";
2
+ import { expect, test } from "@playwright/test";
3
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
4
+ import {
5
+ createOrganizationInDb,
6
+ deleteIntegrationCredentialsInDb,
7
+ deleteOrganizationInDb,
8
+ withClient
9
+ } from "@open-mercato/core/helpers/integration/dbFixtures";
10
+ import { getTokenScope } from "@open-mercato/core/helpers/integration/generalFixtures";
11
+ import { drainIntegrationQueue } from "@open-mercato/core/helpers/integration/queue";
12
+ const WEBHOOK_PATH = "/api/shipping-carriers/webhook/inpost";
13
+ const stamp = `${Date.now()}_${randomUUID().slice(0, 8)}`;
14
+ const webhookSecret = `tcinpost005_${stamp}`;
15
+ async function seedShipment(scope, carrierShipmentId, status) {
16
+ const id = randomUUID();
17
+ await withClient(async (client) => {
18
+ await client.query(
19
+ `insert into carrier_shipments
20
+ (id, order_id, provider_key, carrier_shipment_id, tracking_number, unified_status,
21
+ organization_id, tenant_id, created_at, updated_at)
22
+ values ($1, $2, 'inpost', $3, $3, $4, $5, $6, now(), now())`,
23
+ [id, randomUUID(), carrierShipmentId, status, scope.organizationId, scope.tenantId]
24
+ );
25
+ });
26
+ return id;
27
+ }
28
+ async function readShipment(id) {
29
+ return withClient(async (client) => {
30
+ const result = await client.query(
31
+ "select unified_status, carrier_status from carrier_shipments where id = $1",
32
+ [id]
33
+ );
34
+ return result.rows[0] ?? null;
35
+ });
36
+ }
37
+ async function isWebhookProcessed(idempotencyKey) {
38
+ return withClient(async (client) => {
39
+ const result = await client.query(
40
+ "select 1 from carrier_webhook_events where provider_key = 'inpost' and idempotency_key = $1",
41
+ [idempotencyKey]
42
+ );
43
+ return (result.rowCount ?? 0) > 0;
44
+ });
45
+ }
46
+ async function deliverStatus(request, carrierShipmentId, status) {
47
+ const eventTs = (/* @__PURE__ */ new Date()).toISOString();
48
+ const body = JSON.stringify({
49
+ event: "shipment_status_changed",
50
+ event_ts: eventTs,
51
+ organization_id: 1,
52
+ payload: { shipment_id: carrierShipmentId, status, tracking_number: carrierShipmentId }
53
+ });
54
+ const idempotencyKey = [carrierShipmentId, "shipment_status_changed", status, eventTs].join(":");
55
+ const response = await request.post(WEBHOOK_PATH, {
56
+ data: body,
57
+ headers: {
58
+ "content-type": "application/json",
59
+ "x-inpost-signature": createHmac("sha256", webhookSecret).update(body).digest("hex")
60
+ }
61
+ });
62
+ expect(response.status(), `webhook ${status} should be accepted`).toBe(202);
63
+ await drainIntegrationQueue("shipping-carriers-webhook");
64
+ await expect.poll(() => isWebhookProcessed(idempotencyKey), {
65
+ message: `the ${status} webhook should be processed by the worker`,
66
+ timeout: 3e4
67
+ }).toBe(true);
68
+ }
69
+ test.describe('TC-INPOST-005: InPost "claimed" is a failed delivery, not a cancellation', () => {
70
+ test("keeps a complained parcel trackable until InPost returns it to the sender", async ({ request }) => {
71
+ test.setTimeout(12e4);
72
+ const adminScope = getTokenScope(await getAuthToken(request, "admin"));
73
+ const tenantId = adminScope.tenantId;
74
+ let organizationId = null;
75
+ const shipmentIds = [];
76
+ try {
77
+ organizationId = await createOrganizationInDb({ name: `TC-INPOST-005 ${stamp}`, tenantId });
78
+ const scope = { organizationId, tenantId };
79
+ const superadminToken = await getAuthToken(request, "superadmin");
80
+ const saved = await apiRequest(request, "PUT", "/api/integrations/carrier_inpost/credentials", {
81
+ token: superadminToken,
82
+ headers: {
83
+ Cookie: `om_selected_tenant=${encodeURIComponent(tenantId)}; om_selected_org=${encodeURIComponent(organizationId)}`
84
+ },
85
+ data: { credentials: { apiToken: `tcinpost005_token_${stamp}`, organizationId: "1", webhookSecret } }
86
+ });
87
+ expect(saved.status(), await saved.text()).toBe(200);
88
+ const awaitingPickup = `tcinpost005a${stamp}`;
89
+ const awaitingPickupId = await seedShipment(scope, awaitingPickup, "out_for_delivery");
90
+ shipmentIds.push(awaitingPickupId);
91
+ const pickupExpired = `tcinpost005b${stamp}`;
92
+ const pickupExpiredId = await seedShipment(scope, pickupExpired, "failed_delivery");
93
+ shipmentIds.push(pickupExpiredId);
94
+ await deliverStatus(request, awaitingPickup, "claimed");
95
+ await expect.poll(async () => readShipment(awaitingPickupId), { timeout: 1e4 }).toEqual({ unified_status: "failed_delivery", carrier_status: "claimed" });
96
+ await deliverStatus(request, pickupExpired, "claimed");
97
+ expect(await readShipment(pickupExpiredId)).toEqual({ unified_status: "failed_delivery", carrier_status: null });
98
+ await deliverStatus(request, pickupExpired, "returned_to_sender");
99
+ await expect.poll(async () => (await readShipment(pickupExpiredId))?.unified_status, {
100
+ message: "the return after a complaint must still be applied",
101
+ timeout: 1e4
102
+ }).toBe("returned");
103
+ } finally {
104
+ await withClient(async (client) => {
105
+ if (organizationId) {
106
+ await client.query("delete from carrier_webhook_events where organization_id = $1", [organizationId]);
107
+ }
108
+ if (shipmentIds.length > 0) {
109
+ await client.query("delete from carrier_shipments where id = any($1::uuid[])", [shipmentIds]);
110
+ }
111
+ }).catch(() => void 0);
112
+ await deleteIntegrationCredentialsInDb(organizationId).catch(() => void 0);
113
+ await deleteOrganizationInDb(organizationId).catch(() => void 0);
114
+ }
115
+ });
116
+ });
117
+ //# sourceMappingURL=TC-INPOST-005.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/carrier_inpost/__integration__/TC-INPOST-005.spec.ts"],
4
+ "sourcesContent": ["import { createHmac, randomUUID } from 'node:crypto'\nimport { expect, test, type APIRequestContext } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport {\n createOrganizationInDb,\n deleteIntegrationCredentialsInDb,\n deleteOrganizationInDb,\n withClient,\n} from '@open-mercato/core/helpers/integration/dbFixtures'\nimport { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'\n\n/**\n * TC-INPOST-005: An InPost parcel complaint (\"claimed\") does not cancel the shipment\n *\n * InPost reports `claimed` when the recipient files a complaint at the parcel machine. That is a\n * failed delivery, not a cancelled label: the shipment must stay non-terminal so the carrier's\n * later outcome (here `returned_to_sender`) is still applied.\n *\n * Shipments are seeded with SQL in a throwaway organization (creating a real InPost shipment needs\n * an InPost account). Signed webhooks are POSTed to the real `/api/shipping-carriers/webhook/inpost`\n * route; verification is local HMAC with the organization's saved webhook secret.\n *\n * ENVIRONMENT: API + raw `pg` against `DATABASE_URL`; run under a coherent app+DB stack\n * (`yarn test:integration` / `yarn test:integration:ephemeral`).\n */\n\nconst WEBHOOK_PATH = '/api/shipping-carriers/webhook/inpost'\nconst stamp = `${Date.now()}_${randomUUID().slice(0, 8)}`\nconst webhookSecret = `tcinpost005_${stamp}`\n\ntype Scope = { organizationId: string; tenantId: string }\ntype ShipmentRow = { unified_status: string; carrier_status: string | null }\n\nasync function seedShipment(scope: Scope, carrierShipmentId: string, status: string): Promise<string> {\n const id = randomUUID()\n await withClient(async (client) => {\n await client.query(\n `insert into carrier_shipments\n (id, order_id, provider_key, carrier_shipment_id, tracking_number, unified_status,\n organization_id, tenant_id, created_at, updated_at)\n values ($1, $2, 'inpost', $3, $3, $4, $5, $6, now(), now())`,\n [id, randomUUID(), carrierShipmentId, status, scope.organizationId, scope.tenantId],\n )\n })\n return id\n}\n\nasync function readShipment(id: string): Promise<ShipmentRow | null> {\n return withClient(async (client) => {\n const result = await client.query<ShipmentRow>(\n 'select unified_status, carrier_status from carrier_shipments where id = $1',\n [id],\n )\n return result.rows[0] ?? null\n })\n}\n\nasync function isWebhookProcessed(idempotencyKey: string): Promise<boolean> {\n return withClient(async (client) => {\n const result = await client.query(\n \"select 1 from carrier_webhook_events where provider_key = 'inpost' and idempotency_key = $1\",\n [idempotencyKey],\n )\n return (result.rowCount ?? 0) > 0\n })\n}\n\nasync function deliverStatus(\n request: APIRequestContext,\n carrierShipmentId: string,\n status: string,\n): Promise<void> {\n const eventTs = new Date().toISOString()\n const body = JSON.stringify({\n event: 'shipment_status_changed',\n event_ts: eventTs,\n organization_id: 1,\n payload: { shipment_id: carrierShipmentId, status, tracking_number: carrierShipmentId },\n })\n const idempotencyKey = [carrierShipmentId, 'shipment_status_changed', status, eventTs].join(':')\n const response = await request.post(WEBHOOK_PATH, {\n data: body,\n headers: {\n 'content-type': 'application/json',\n 'x-inpost-signature': createHmac('sha256', webhookSecret).update(body).digest('hex'),\n },\n })\n expect(response.status(), `webhook ${status} should be accepted`).toBe(202)\n await drainIntegrationQueue('shipping-carriers-webhook')\n await expect\n .poll(() => isWebhookProcessed(idempotencyKey), {\n message: `the ${status} webhook should be processed by the worker`,\n timeout: 30_000,\n })\n .toBe(true)\n}\n\ntest.describe('TC-INPOST-005: InPost \"claimed\" is a failed delivery, not a cancellation', () => {\n test('keeps a complained parcel trackable until InPost returns it to the sender', async ({ request }) => {\n test.setTimeout(120_000)\n const adminScope = getTokenScope(await getAuthToken(request, 'admin'))\n const tenantId = adminScope.tenantId\n let organizationId: string | null = null\n const shipmentIds: string[] = []\n\n try {\n organizationId = await createOrganizationInDb({ name: `TC-INPOST-005 ${stamp}`, tenantId })\n const scope: Scope = { organizationId, tenantId }\n const superadminToken = await getAuthToken(request, 'superadmin')\n const saved = await apiRequest(request, 'PUT', '/api/integrations/carrier_inpost/credentials', {\n token: superadminToken,\n headers: {\n Cookie: `om_selected_tenant=${encodeURIComponent(tenantId)}; om_selected_org=${encodeURIComponent(organizationId)}`,\n },\n data: { credentials: { apiToken: `tcinpost005_token_${stamp}`, organizationId: '1', webhookSecret } },\n })\n expect(saved.status(), await saved.text()).toBe(200)\n\n const awaitingPickup = `tcinpost005a${stamp}`\n const awaitingPickupId = await seedShipment(scope, awaitingPickup, 'out_for_delivery')\n shipmentIds.push(awaitingPickupId)\n const pickupExpired = `tcinpost005b${stamp}`\n const pickupExpiredId = await seedShipment(scope, pickupExpired, 'failed_delivery')\n shipmentIds.push(pickupExpiredId)\n\n await deliverStatus(request, awaitingPickup, 'claimed')\n await expect\n .poll(async () => readShipment(awaitingPickupId), { timeout: 10_000 })\n .toEqual({ unified_status: 'failed_delivery', carrier_status: 'claimed' })\n\n await deliverStatus(request, pickupExpired, 'claimed')\n expect(await readShipment(pickupExpiredId)).toEqual({ unified_status: 'failed_delivery', carrier_status: null })\n\n await deliverStatus(request, pickupExpired, 'returned_to_sender')\n await expect\n .poll(async () => (await readShipment(pickupExpiredId))?.unified_status, {\n message: 'the return after a complaint must still be applied',\n timeout: 10_000,\n })\n .toBe('returned')\n } finally {\n await withClient(async (client) => {\n if (organizationId) {\n await client.query('delete from carrier_webhook_events where organization_id = $1', [organizationId])\n }\n if (shipmentIds.length > 0) {\n await client.query('delete from carrier_shipments where id = any($1::uuid[])', [shipmentIds])\n }\n }).catch(() => undefined)\n await deleteIntegrationCredentialsInDb(organizationId).catch(() => undefined)\n await deleteOrganizationInDb(organizationId).catch(() => undefined)\n }\n })\n})\n"],
5
+ "mappings": "AAAA,SAAS,YAAY,kBAAkB;AACvC,SAAS,QAAQ,YAAoC;AACrD,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAC9B,SAAS,6BAA6B;AAiBtC,MAAM,eAAe;AACrB,MAAM,QAAQ,GAAG,KAAK,IAAI,CAAC,IAAI,WAAW,EAAE,MAAM,GAAG,CAAC,CAAC;AACvD,MAAM,gBAAgB,eAAe,KAAK;AAK1C,eAAe,aAAa,OAAc,mBAA2B,QAAiC;AACpG,QAAM,KAAK,WAAW;AACtB,QAAM,WAAW,OAAO,WAAW;AACjC,UAAM,OAAO;AAAA,MACX;AAAA;AAAA;AAAA;AAAA,MAIA,CAAC,IAAI,WAAW,GAAG,mBAAmB,QAAQ,MAAM,gBAAgB,MAAM,QAAQ;AAAA,IACpF;AAAA,EACF,CAAC;AACD,SAAO;AACT;AAEA,eAAe,aAAa,IAAyC;AACnE,SAAO,WAAW,OAAO,WAAW;AAClC,UAAM,SAAS,MAAM,OAAO;AAAA,MAC1B;AAAA,MACA,CAAC,EAAE;AAAA,IACL;AACA,WAAO,OAAO,KAAK,CAAC,KAAK;AAAA,EAC3B,CAAC;AACH;AAEA,eAAe,mBAAmB,gBAA0C;AAC1E,SAAO,WAAW,OAAO,WAAW;AAClC,UAAM,SAAS,MAAM,OAAO;AAAA,MAC1B;AAAA,MACA,CAAC,cAAc;AAAA,IACjB;AACA,YAAQ,OAAO,YAAY,KAAK;AAAA,EAClC,CAAC;AACH;AAEA,eAAe,cACb,SACA,mBACA,QACe;AACf,QAAM,WAAU,oBAAI,KAAK,GAAE,YAAY;AACvC,QAAM,OAAO,KAAK,UAAU;AAAA,IAC1B,OAAO;AAAA,IACP,UAAU;AAAA,IACV,iBAAiB;AAAA,IACjB,SAAS,EAAE,aAAa,mBAAmB,QAAQ,iBAAiB,kBAAkB;AAAA,EACxF,CAAC;AACD,QAAM,iBAAiB,CAAC,mBAAmB,2BAA2B,QAAQ,OAAO,EAAE,KAAK,GAAG;AAC/F,QAAM,WAAW,MAAM,QAAQ,KAAK,cAAc;AAAA,IAChD,MAAM;AAAA,IACN,SAAS;AAAA,MACP,gBAAgB;AAAA,MAChB,sBAAsB,WAAW,UAAU,aAAa,EAAE,OAAO,IAAI,EAAE,OAAO,KAAK;AAAA,IACrF;AAAA,EACF,CAAC;AACD,SAAO,SAAS,OAAO,GAAG,WAAW,MAAM,qBAAqB,EAAE,KAAK,GAAG;AAC1E,QAAM,sBAAsB,2BAA2B;AACvD,QAAM,OACH,KAAK,MAAM,mBAAmB,cAAc,GAAG;AAAA,IAC9C,SAAS,OAAO,MAAM;AAAA,IACtB,SAAS;AAAA,EACX,CAAC,EACA,KAAK,IAAI;AACd;AAEA,KAAK,SAAS,4EAA4E,MAAM;AAC9F,OAAK,6EAA6E,OAAO,EAAE,QAAQ,MAAM;AACvG,SAAK,WAAW,IAAO;AACvB,UAAM,aAAa,cAAc,MAAM,aAAa,SAAS,OAAO,CAAC;AACrE,UAAM,WAAW,WAAW;AAC5B,QAAI,iBAAgC;AACpC,UAAM,cAAwB,CAAC;AAE/B,QAAI;AACF,uBAAiB,MAAM,uBAAuB,EAAE,MAAM,iBAAiB,KAAK,IAAI,SAAS,CAAC;AAC1F,YAAM,QAAe,EAAE,gBAAgB,SAAS;AAChD,YAAM,kBAAkB,MAAM,aAAa,SAAS,YAAY;AAChE,YAAM,QAAQ,MAAM,WAAW,SAAS,OAAO,gDAAgD;AAAA,QAC7F,OAAO;AAAA,QACP,SAAS;AAAA,UACP,QAAQ,sBAAsB,mBAAmB,QAAQ,CAAC,qBAAqB,mBAAmB,cAAc,CAAC;AAAA,QACnH;AAAA,QACA,MAAM,EAAE,aAAa,EAAE,UAAU,qBAAqB,KAAK,IAAI,gBAAgB,KAAK,cAAc,EAAE;AAAA,MACtG,CAAC;AACD,aAAO,MAAM,OAAO,GAAG,MAAM,MAAM,KAAK,CAAC,EAAE,KAAK,GAAG;AAEnD,YAAM,iBAAiB,eAAe,KAAK;AAC3C,YAAM,mBAAmB,MAAM,aAAa,OAAO,gBAAgB,kBAAkB;AACrF,kBAAY,KAAK,gBAAgB;AACjC,YAAM,gBAAgB,eAAe,KAAK;AAC1C,YAAM,kBAAkB,MAAM,aAAa,OAAO,eAAe,iBAAiB;AAClF,kBAAY,KAAK,eAAe;AAEhC,YAAM,cAAc,SAAS,gBAAgB,SAAS;AACtD,YAAM,OACH,KAAK,YAAY,aAAa,gBAAgB,GAAG,EAAE,SAAS,IAAO,CAAC,EACpE,QAAQ,EAAE,gBAAgB,mBAAmB,gBAAgB,UAAU,CAAC;AAE3E,YAAM,cAAc,SAAS,eAAe,SAAS;AACrD,aAAO,MAAM,aAAa,eAAe,CAAC,EAAE,QAAQ,EAAE,gBAAgB,mBAAmB,gBAAgB,KAAK,CAAC;AAE/G,YAAM,cAAc,SAAS,eAAe,oBAAoB;AAChE,YAAM,OACH,KAAK,aAAa,MAAM,aAAa,eAAe,IAAI,gBAAgB;AAAA,QACvE,SAAS;AAAA,QACT,SAAS;AAAA,MACX,CAAC,EACA,KAAK,UAAU;AAAA,IACpB,UAAE;AACA,YAAM,WAAW,OAAO,WAAW;AACjC,YAAI,gBAAgB;AAClB,gBAAM,OAAO,MAAM,iEAAiE,CAAC,cAAc,CAAC;AAAA,QACtG;AACA,YAAI,YAAY,SAAS,GAAG;AAC1B,gBAAM,OAAO,MAAM,4DAA4D,CAAC,WAAW,CAAC;AAAA,QAC9F;AAAA,MACF,CAAC,EAAE,MAAM,MAAM,MAAS;AACxB,YAAM,iCAAiC,cAAc,EAAE,MAAM,MAAM,MAAS;AAC5E,YAAM,uBAAuB,cAAc,EAAE,MAAM,MAAM,MAAS;AAAA,IACpE;AAAA,EACF,CAAC;AACH,CAAC;",
6
+ "names": []
7
+ }
@@ -44,11 +44,11 @@ const INPOST_STATUS_MAP = {
44
44
  undelivered_wrong_address: "failed_delivery",
45
45
  undelivered_cod_cash_receiver: "failed_delivery",
46
46
  rejected_by_receiver: "failed_delivery",
47
+ claimed: "failed_delivery",
47
48
  // Returned
48
49
  returned_to_sender: "returned",
49
50
  // Cancelled
50
- canceled: "cancelled",
51
- claimed: "cancelled"
51
+ canceled: "cancelled"
52
52
  };
53
53
  function mapInpostStatus(inpostStatus) {
54
54
  return INPOST_STATUS_MAP[inpostStatus] ?? "unknown";
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/carrier_inpost/lib/status-map.ts"],
4
- "sourcesContent": ["import type { UnifiedShipmentStatus } from '@open-mercato/core/modules/shipping_carriers/lib/adapter'\n\nconst INPOST_STATUS_MAP: Record<string, UnifiedShipmentStatus> = {\n // Label / offer preparation\n created: 'label_created',\n offers_prepared: 'label_created',\n offer_selected: 'label_created',\n confirmed: 'label_created',\n\n // Picked up from sender\n dispatched_by_sender: 'picked_up',\n dispatched_by_sender_to_pok: 'picked_up',\n collected_from_sender: 'picked_up',\n taken_by_courier_from_customer_service_point: 'picked_up',\n taken_by_courier_from_pok: 'picked_up',\n\n // In transit / sorting\n taken_by_courier: 'in_transit',\n adopted_at_source_branch: 'in_transit',\n sent_from_source_branch: 'in_transit',\n adopted_at_sorting_center: 'in_transit',\n oversized: 'in_transit',\n readdressed: 'in_transit',\n delay_in_delivery: 'in_transit',\n unstack_from_customer_service_point: 'in_transit',\n unstack_from_box_machine: 'in_transit',\n redirect_to_box: 'in_transit',\n canceled_redirect_to_box: 'in_transit',\n\n // Out for delivery / awaiting pickup\n out_for_delivery: 'out_for_delivery',\n out_for_delivery_to_address: 'out_for_delivery',\n ready_to_pickup: 'out_for_delivery',\n ready_to_pickup_from_pok: 'out_for_delivery',\n ready_to_pickup_from_branch: 'out_for_delivery',\n stack_in_box_machine: 'out_for_delivery',\n stack_in_customer_service_point: 'out_for_delivery',\n pickup_reminder_sent: 'out_for_delivery',\n pickup_reminder_sent_address: 'out_for_delivery',\n courier_avizo_in_customer_service_point: 'out_for_delivery',\n\n // Delivered\n delivered: 'delivered',\n\n // Failed delivery\n pickup_time_expired: 'failed_delivery',\n stack_parcel_pickup_time_expired: 'failed_delivery',\n stack_parcel_in_box_machine_pickup_time_expired: 'failed_delivery',\n avizo: 'failed_delivery',\n undelivered: 'failed_delivery',\n undelivered_wrong_address: 'failed_delivery',\n undelivered_cod_cash_receiver: 'failed_delivery',\n rejected_by_receiver: 'failed_delivery',\n\n // Returned\n returned_to_sender: 'returned',\n\n // Cancelled\n canceled: 'cancelled',\n claimed: 'cancelled',\n}\n\nexport function mapInpostStatus(inpostStatus: string): UnifiedShipmentStatus {\n return INPOST_STATUS_MAP[inpostStatus] ?? 'unknown'\n}\n\nexport const SERVICE_CODE_MAP: Record<string, string> = {\n locker_standard: 'inpost_locker_standard',\n courier_standard: 'inpost_courier_standard',\n courier_c2c: 'inpost_courier_c2c',\n}\n\n// Locker services use template-based parcel sizing (small/medium/large).\n// courier_c2c also uses templates (small/medium/large/xlarge) but is NOT\n// included here because isLockerService is used to add locker-specific\n// API fields (e.g. target_point, receiver email) \u2014 courier_c2c does not need those.\nconst LOCKER_SERVICE_CODES = new Set([\n 'locker_standard',\n 'inpost_locker_standard',\n 'inpost_locker_allegro',\n 'inpost_locker_pass_thru',\n 'inpost_locker_standard_smart',\n 'inpost_locker_allegro_smart',\n])\n\nexport function isLockerService(serviceCode: string): boolean {\n return LOCKER_SERVICE_CODES.has(serviceCode)\n}\n\nexport function mapServiceCodeToInpost(serviceCode: string): string {\n return SERVICE_CODE_MAP[serviceCode] ?? serviceCode\n}\n"],
5
- "mappings": "AAEA,MAAM,oBAA2D;AAAA;AAAA,EAE/D,SAAS;AAAA,EACT,iBAAiB;AAAA,EACjB,gBAAgB;AAAA,EAChB,WAAW;AAAA;AAAA,EAGX,sBAAsB;AAAA,EACtB,6BAA6B;AAAA,EAC7B,uBAAuB;AAAA,EACvB,8CAA8C;AAAA,EAC9C,2BAA2B;AAAA;AAAA,EAG3B,kBAAkB;AAAA,EAClB,0BAA0B;AAAA,EAC1B,yBAAyB;AAAA,EACzB,2BAA2B;AAAA,EAC3B,WAAW;AAAA,EACX,aAAa;AAAA,EACb,mBAAmB;AAAA,EACnB,qCAAqC;AAAA,EACrC,0BAA0B;AAAA,EAC1B,iBAAiB;AAAA,EACjB,0BAA0B;AAAA;AAAA,EAG1B,kBAAkB;AAAA,EAClB,6BAA6B;AAAA,EAC7B,iBAAiB;AAAA,EACjB,0BAA0B;AAAA,EAC1B,6BAA6B;AAAA,EAC7B,sBAAsB;AAAA,EACtB,iCAAiC;AAAA,EACjC,sBAAsB;AAAA,EACtB,8BAA8B;AAAA,EAC9B,yCAAyC;AAAA;AAAA,EAGzC,WAAW;AAAA;AAAA,EAGX,qBAAqB;AAAA,EACrB,kCAAkC;AAAA,EAClC,iDAAiD;AAAA,EACjD,OAAO;AAAA,EACP,aAAa;AAAA,EACb,2BAA2B;AAAA,EAC3B,+BAA+B;AAAA,EAC/B,sBAAsB;AAAA;AAAA,EAGtB,oBAAoB;AAAA;AAAA,EAGpB,UAAU;AAAA,EACV,SAAS;AACX;AAEO,SAAS,gBAAgB,cAA6C;AAC3E,SAAO,kBAAkB,YAAY,KAAK;AAC5C;AAEO,MAAM,mBAA2C;AAAA,EACtD,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB,aAAa;AACf;AAMA,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,SAAS,gBAAgB,aAA8B;AAC5D,SAAO,qBAAqB,IAAI,WAAW;AAC7C;AAEO,SAAS,uBAAuB,aAA6B;AAClE,SAAO,iBAAiB,WAAW,KAAK;AAC1C;",
4
+ "sourcesContent": ["import type { UnifiedShipmentStatus } from '@open-mercato/core/modules/shipping_carriers/lib/adapter'\n\nconst INPOST_STATUS_MAP: Record<string, UnifiedShipmentStatus> = {\n // Label / offer preparation\n created: 'label_created',\n offers_prepared: 'label_created',\n offer_selected: 'label_created',\n confirmed: 'label_created',\n\n // Picked up from sender\n dispatched_by_sender: 'picked_up',\n dispatched_by_sender_to_pok: 'picked_up',\n collected_from_sender: 'picked_up',\n taken_by_courier_from_customer_service_point: 'picked_up',\n taken_by_courier_from_pok: 'picked_up',\n\n // In transit / sorting\n taken_by_courier: 'in_transit',\n adopted_at_source_branch: 'in_transit',\n sent_from_source_branch: 'in_transit',\n adopted_at_sorting_center: 'in_transit',\n oversized: 'in_transit',\n readdressed: 'in_transit',\n delay_in_delivery: 'in_transit',\n unstack_from_customer_service_point: 'in_transit',\n unstack_from_box_machine: 'in_transit',\n redirect_to_box: 'in_transit',\n canceled_redirect_to_box: 'in_transit',\n\n // Out for delivery / awaiting pickup\n out_for_delivery: 'out_for_delivery',\n out_for_delivery_to_address: 'out_for_delivery',\n ready_to_pickup: 'out_for_delivery',\n ready_to_pickup_from_pok: 'out_for_delivery',\n ready_to_pickup_from_branch: 'out_for_delivery',\n stack_in_box_machine: 'out_for_delivery',\n stack_in_customer_service_point: 'out_for_delivery',\n pickup_reminder_sent: 'out_for_delivery',\n pickup_reminder_sent_address: 'out_for_delivery',\n courier_avizo_in_customer_service_point: 'out_for_delivery',\n\n // Delivered\n delivered: 'delivered',\n\n // Failed delivery\n pickup_time_expired: 'failed_delivery',\n stack_parcel_pickup_time_expired: 'failed_delivery',\n stack_parcel_in_box_machine_pickup_time_expired: 'failed_delivery',\n avizo: 'failed_delivery',\n undelivered: 'failed_delivery',\n undelivered_wrong_address: 'failed_delivery',\n undelivered_cod_cash_receiver: 'failed_delivery',\n rejected_by_receiver: 'failed_delivery',\n claimed: 'failed_delivery',\n\n // Returned\n returned_to_sender: 'returned',\n\n // Cancelled\n canceled: 'cancelled',\n}\n\nexport function mapInpostStatus(inpostStatus: string): UnifiedShipmentStatus {\n return INPOST_STATUS_MAP[inpostStatus] ?? 'unknown'\n}\n\nexport const SERVICE_CODE_MAP: Record<string, string> = {\n locker_standard: 'inpost_locker_standard',\n courier_standard: 'inpost_courier_standard',\n courier_c2c: 'inpost_courier_c2c',\n}\n\n// Locker services use template-based parcel sizing (small/medium/large).\n// courier_c2c also uses templates (small/medium/large/xlarge) but is NOT\n// included here because isLockerService is used to add locker-specific\n// API fields (e.g. target_point, receiver email) \u2014 courier_c2c does not need those.\nconst LOCKER_SERVICE_CODES = new Set([\n 'locker_standard',\n 'inpost_locker_standard',\n 'inpost_locker_allegro',\n 'inpost_locker_pass_thru',\n 'inpost_locker_standard_smart',\n 'inpost_locker_allegro_smart',\n])\n\nexport function isLockerService(serviceCode: string): boolean {\n return LOCKER_SERVICE_CODES.has(serviceCode)\n}\n\nexport function mapServiceCodeToInpost(serviceCode: string): string {\n return SERVICE_CODE_MAP[serviceCode] ?? serviceCode\n}\n"],
5
+ "mappings": "AAEA,MAAM,oBAA2D;AAAA;AAAA,EAE/D,SAAS;AAAA,EACT,iBAAiB;AAAA,EACjB,gBAAgB;AAAA,EAChB,WAAW;AAAA;AAAA,EAGX,sBAAsB;AAAA,EACtB,6BAA6B;AAAA,EAC7B,uBAAuB;AAAA,EACvB,8CAA8C;AAAA,EAC9C,2BAA2B;AAAA;AAAA,EAG3B,kBAAkB;AAAA,EAClB,0BAA0B;AAAA,EAC1B,yBAAyB;AAAA,EACzB,2BAA2B;AAAA,EAC3B,WAAW;AAAA,EACX,aAAa;AAAA,EACb,mBAAmB;AAAA,EACnB,qCAAqC;AAAA,EACrC,0BAA0B;AAAA,EAC1B,iBAAiB;AAAA,EACjB,0BAA0B;AAAA;AAAA,EAG1B,kBAAkB;AAAA,EAClB,6BAA6B;AAAA,EAC7B,iBAAiB;AAAA,EACjB,0BAA0B;AAAA,EAC1B,6BAA6B;AAAA,EAC7B,sBAAsB;AAAA,EACtB,iCAAiC;AAAA,EACjC,sBAAsB;AAAA,EACtB,8BAA8B;AAAA,EAC9B,yCAAyC;AAAA;AAAA,EAGzC,WAAW;AAAA;AAAA,EAGX,qBAAqB;AAAA,EACrB,kCAAkC;AAAA,EAClC,iDAAiD;AAAA,EACjD,OAAO;AAAA,EACP,aAAa;AAAA,EACb,2BAA2B;AAAA,EAC3B,+BAA+B;AAAA,EAC/B,sBAAsB;AAAA,EACtB,SAAS;AAAA;AAAA,EAGT,oBAAoB;AAAA;AAAA,EAGpB,UAAU;AACZ;AAEO,SAAS,gBAAgB,cAA6C;AAC3E,SAAO,kBAAkB,YAAY,KAAK;AAC5C;AAEO,MAAM,mBAA2C;AAAA,EACtD,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB,aAAa;AACf;AAMA,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,SAAS,gBAAgB,aAA8B;AAC5D,SAAO,qBAAqB,IAAI,WAAW;AAC7C;AAEO,SAAS,uBAAuB,aAA6B;AAClE,SAAO,iBAAiB,WAAW,KAAK;AAC1C;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/carrier-inpost",
3
- "version": "0.10.1-develop.8062.1.f29b9ae445",
3
+ "version": "0.10.1-develop.8075.1.f2d38008c0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,21 +62,21 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.10.1-develop.8062.1.f29b9ae445",
66
- "@open-mercato/ui": "0.10.1-develop.8062.1.f29b9ae445",
65
+ "@open-mercato/core": "0.10.1-develop.8075.1.f2d38008c0",
66
+ "@open-mercato/ui": "0.10.1-develop.8075.1.f2d38008c0",
67
67
  "ts-pattern": "^5.0.0"
68
68
  },
69
69
  "peerDependencies": {
70
70
  "@mikro-orm/postgresql": "^7.0.14",
71
- "@open-mercato/ai-assistant": "0.10.1-develop.8062.1.f29b9ae445",
72
- "@open-mercato/shared": "0.10.1-develop.8062.1.f29b9ae445",
71
+ "@open-mercato/ai-assistant": "0.10.1-develop.8075.1.f2d38008c0",
72
+ "@open-mercato/shared": "0.10.1-develop.8075.1.f2d38008c0",
73
73
  "react": "^19.0.0",
74
74
  "react-dom": "^19.0.0",
75
75
  "react-is": ">=18.0.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@open-mercato/ai-assistant": "0.10.1-develop.8062.1.f29b9ae445",
79
- "@open-mercato/shared": "0.10.1-develop.8062.1.f29b9ae445",
78
+ "@open-mercato/ai-assistant": "0.10.1-develop.8075.1.f2d38008c0",
79
+ "@open-mercato/shared": "0.10.1-develop.8075.1.f2d38008c0",
80
80
  "@types/chance": "^1.1.8",
81
81
  "@types/jest": "^30.0.0",
82
82
  "@types/react": "^19.2.18",
@@ -0,0 +1,155 @@
1
+ import { createHmac, randomUUID } from 'node:crypto'
2
+ import { expect, test, type APIRequestContext } from '@playwright/test'
3
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
4
+ import {
5
+ createOrganizationInDb,
6
+ deleteIntegrationCredentialsInDb,
7
+ deleteOrganizationInDb,
8
+ withClient,
9
+ } from '@open-mercato/core/helpers/integration/dbFixtures'
10
+ import { getTokenScope } from '@open-mercato/core/helpers/integration/generalFixtures'
11
+ import { drainIntegrationQueue } from '@open-mercato/core/helpers/integration/queue'
12
+
13
+ /**
14
+ * TC-INPOST-005: An InPost parcel complaint ("claimed") does not cancel the shipment
15
+ *
16
+ * InPost reports `claimed` when the recipient files a complaint at the parcel machine. That is a
17
+ * failed delivery, not a cancelled label: the shipment must stay non-terminal so the carrier's
18
+ * later outcome (here `returned_to_sender`) is still applied.
19
+ *
20
+ * Shipments are seeded with SQL in a throwaway organization (creating a real InPost shipment needs
21
+ * an InPost account). Signed webhooks are POSTed to the real `/api/shipping-carriers/webhook/inpost`
22
+ * route; verification is local HMAC with the organization's saved webhook secret.
23
+ *
24
+ * ENVIRONMENT: API + raw `pg` against `DATABASE_URL`; run under a coherent app+DB stack
25
+ * (`yarn test:integration` / `yarn test:integration:ephemeral`).
26
+ */
27
+
28
+ const WEBHOOK_PATH = '/api/shipping-carriers/webhook/inpost'
29
+ const stamp = `${Date.now()}_${randomUUID().slice(0, 8)}`
30
+ const webhookSecret = `tcinpost005_${stamp}`
31
+
32
+ type Scope = { organizationId: string; tenantId: string }
33
+ type ShipmentRow = { unified_status: string; carrier_status: string | null }
34
+
35
+ async function seedShipment(scope: Scope, carrierShipmentId: string, status: string): Promise<string> {
36
+ const id = randomUUID()
37
+ await withClient(async (client) => {
38
+ await client.query(
39
+ `insert into carrier_shipments
40
+ (id, order_id, provider_key, carrier_shipment_id, tracking_number, unified_status,
41
+ organization_id, tenant_id, created_at, updated_at)
42
+ values ($1, $2, 'inpost', $3, $3, $4, $5, $6, now(), now())`,
43
+ [id, randomUUID(), carrierShipmentId, status, scope.organizationId, scope.tenantId],
44
+ )
45
+ })
46
+ return id
47
+ }
48
+
49
+ async function readShipment(id: string): Promise<ShipmentRow | null> {
50
+ return withClient(async (client) => {
51
+ const result = await client.query<ShipmentRow>(
52
+ 'select unified_status, carrier_status from carrier_shipments where id = $1',
53
+ [id],
54
+ )
55
+ return result.rows[0] ?? null
56
+ })
57
+ }
58
+
59
+ async function isWebhookProcessed(idempotencyKey: string): Promise<boolean> {
60
+ return withClient(async (client) => {
61
+ const result = await client.query(
62
+ "select 1 from carrier_webhook_events where provider_key = 'inpost' and idempotency_key = $1",
63
+ [idempotencyKey],
64
+ )
65
+ return (result.rowCount ?? 0) > 0
66
+ })
67
+ }
68
+
69
+ async function deliverStatus(
70
+ request: APIRequestContext,
71
+ carrierShipmentId: string,
72
+ status: string,
73
+ ): Promise<void> {
74
+ const eventTs = new Date().toISOString()
75
+ const body = JSON.stringify({
76
+ event: 'shipment_status_changed',
77
+ event_ts: eventTs,
78
+ organization_id: 1,
79
+ payload: { shipment_id: carrierShipmentId, status, tracking_number: carrierShipmentId },
80
+ })
81
+ const idempotencyKey = [carrierShipmentId, 'shipment_status_changed', status, eventTs].join(':')
82
+ const response = await request.post(WEBHOOK_PATH, {
83
+ data: body,
84
+ headers: {
85
+ 'content-type': 'application/json',
86
+ 'x-inpost-signature': createHmac('sha256', webhookSecret).update(body).digest('hex'),
87
+ },
88
+ })
89
+ expect(response.status(), `webhook ${status} should be accepted`).toBe(202)
90
+ await drainIntegrationQueue('shipping-carriers-webhook')
91
+ await expect
92
+ .poll(() => isWebhookProcessed(idempotencyKey), {
93
+ message: `the ${status} webhook should be processed by the worker`,
94
+ timeout: 30_000,
95
+ })
96
+ .toBe(true)
97
+ }
98
+
99
+ test.describe('TC-INPOST-005: InPost "claimed" is a failed delivery, not a cancellation', () => {
100
+ test('keeps a complained parcel trackable until InPost returns it to the sender', async ({ request }) => {
101
+ test.setTimeout(120_000)
102
+ const adminScope = getTokenScope(await getAuthToken(request, 'admin'))
103
+ const tenantId = adminScope.tenantId
104
+ let organizationId: string | null = null
105
+ const shipmentIds: string[] = []
106
+
107
+ try {
108
+ organizationId = await createOrganizationInDb({ name: `TC-INPOST-005 ${stamp}`, tenantId })
109
+ const scope: Scope = { organizationId, tenantId }
110
+ const superadminToken = await getAuthToken(request, 'superadmin')
111
+ const saved = await apiRequest(request, 'PUT', '/api/integrations/carrier_inpost/credentials', {
112
+ token: superadminToken,
113
+ headers: {
114
+ Cookie: `om_selected_tenant=${encodeURIComponent(tenantId)}; om_selected_org=${encodeURIComponent(organizationId)}`,
115
+ },
116
+ data: { credentials: { apiToken: `tcinpost005_token_${stamp}`, organizationId: '1', webhookSecret } },
117
+ })
118
+ expect(saved.status(), await saved.text()).toBe(200)
119
+
120
+ const awaitingPickup = `tcinpost005a${stamp}`
121
+ const awaitingPickupId = await seedShipment(scope, awaitingPickup, 'out_for_delivery')
122
+ shipmentIds.push(awaitingPickupId)
123
+ const pickupExpired = `tcinpost005b${stamp}`
124
+ const pickupExpiredId = await seedShipment(scope, pickupExpired, 'failed_delivery')
125
+ shipmentIds.push(pickupExpiredId)
126
+
127
+ await deliverStatus(request, awaitingPickup, 'claimed')
128
+ await expect
129
+ .poll(async () => readShipment(awaitingPickupId), { timeout: 10_000 })
130
+ .toEqual({ unified_status: 'failed_delivery', carrier_status: 'claimed' })
131
+
132
+ await deliverStatus(request, pickupExpired, 'claimed')
133
+ expect(await readShipment(pickupExpiredId)).toEqual({ unified_status: 'failed_delivery', carrier_status: null })
134
+
135
+ await deliverStatus(request, pickupExpired, 'returned_to_sender')
136
+ await expect
137
+ .poll(async () => (await readShipment(pickupExpiredId))?.unified_status, {
138
+ message: 'the return after a complaint must still be applied',
139
+ timeout: 10_000,
140
+ })
141
+ .toBe('returned')
142
+ } finally {
143
+ await withClient(async (client) => {
144
+ if (organizationId) {
145
+ await client.query('delete from carrier_webhook_events where organization_id = $1', [organizationId])
146
+ }
147
+ if (shipmentIds.length > 0) {
148
+ await client.query('delete from carrier_shipments where id = any($1::uuid[])', [shipmentIds])
149
+ }
150
+ }).catch(() => undefined)
151
+ await deleteIntegrationCredentialsInDb(organizationId).catch(() => undefined)
152
+ await deleteOrganizationInDb(organizationId).catch(() => undefined)
153
+ }
154
+ })
155
+ })
@@ -1,4 +1,9 @@
1
1
  import Chance from 'chance'
2
+ import {
3
+ getTerminalShippingEvent,
4
+ syncShipmentStatus,
5
+ TERMINAL_SHIPPING_STATUSES,
6
+ } from '@open-mercato/core/modules/shipping_carriers/lib/status-sync'
2
7
  import { mapInpostStatus, mapServiceCodeToInpost, isLockerService } from '../lib/status-map'
3
8
 
4
9
  const chance = new Chance()
@@ -55,13 +60,13 @@ describe('mapInpostStatus', () => {
55
60
  ['undelivered_wrong_address', 'failed_delivery'],
56
61
  ['undelivered_cod_cash_receiver', 'failed_delivery'],
57
62
  ['rejected_by_receiver', 'failed_delivery'],
63
+ ['claimed', 'failed_delivery'],
58
64
 
59
65
  // Returned
60
66
  ['returned_to_sender', 'returned'],
61
67
 
62
68
  // Cancelled
63
69
  ['canceled', 'cancelled'],
64
- ['claimed', 'cancelled'],
65
70
  ]
66
71
 
67
72
  it.each(cases)('maps InPost status "%s" to unified "%s"', (inpostStatus, expected) => {
@@ -77,6 +82,21 @@ describe('mapInpostStatus', () => {
77
82
  it('returns "unknown" for sent_from_sorting_center (not in official status list)', () => {
78
83
  expect(mapInpostStatus('sent_from_sorting_center')).toBe('unknown')
79
84
  })
85
+
86
+ it('does not treat a parcel complaint ("claimed") as a terminal cancellation', () => {
87
+ const mapped = mapInpostStatus('claimed')
88
+ expect(mapped).not.toBe(mapInpostStatus('canceled'))
89
+ expect(TERMINAL_SHIPPING_STATUSES.has(mapped)).toBe(false)
90
+ expect(getTerminalShippingEvent(mapped)).toBeNull()
91
+ })
92
+
93
+ it('keeps syncing a shipment after a complaint so the parcel return is still recorded', () => {
94
+ const shipment = { unifiedStatus: 'failed_delivery' } as Parameters<typeof syncShipmentStatus>[0]
95
+ expect(syncShipmentStatus(shipment, mapInpostStatus('claimed'))).toBe(false)
96
+ expect(shipment.unifiedStatus).toBe('failed_delivery')
97
+ expect(syncShipmentStatus(shipment, mapInpostStatus('returned_to_sender'))).toBe(true)
98
+ expect(shipment.unifiedStatus).toBe('returned')
99
+ })
80
100
  })
81
101
 
82
102
  describe('mapServiceCodeToInpost', () => {
@@ -51,13 +51,13 @@ const INPOST_STATUS_MAP: Record<string, UnifiedShipmentStatus> = {
51
51
  undelivered_wrong_address: 'failed_delivery',
52
52
  undelivered_cod_cash_receiver: 'failed_delivery',
53
53
  rejected_by_receiver: 'failed_delivery',
54
+ claimed: 'failed_delivery',
54
55
 
55
56
  // Returned
56
57
  returned_to_sender: 'returned',
57
58
 
58
59
  // Cancelled
59
60
  canceled: 'cancelled',
60
- claimed: 'cancelled',
61
61
  }
62
62
 
63
63
  export function mapInpostStatus(inpostStatus: string): UnifiedShipmentStatus {