Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions hooks/__tests__/fixtures/cargoApprovalApiResponses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { CargoApproval } from '@/types/cargoApproval';

export const SIGNER_FLEET_MANAGER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H';
export const SIGNER_SHIPPER = 'GBFRZ3E3AAFGBY6JIAX6OFEBW5JVE7KMU6MVADXXWTRLZ25TK22ENLCW';
export const SIGNER_INSURER = 'GCBAWFKS45CQA4C4BUIXM57Z2YKSP6HPIWXXUF7ZJVM6WTLNRAWML26O';
export const NON_SIGNER = 'GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37';

/** Threshold 3: fleet manager (2) already signed, shipper (1) and insurer (1) outstanding. */
export const pendingApprovalFixture: CargoApproval = {
escrowId: 'escrow-hv-001',
threshold: 3,
signers: [
{
publicKey: SIGNER_FLEET_MANAGER,
weight: 2,
hasSigned: true,
signedAt: '2026-09-28T08:00:00.000Z',
label: 'Fleet manager',
},
{ publicKey: SIGNER_SHIPPER, weight: 1, hasSigned: false, signedAt: null, label: 'Shipper' },
{ publicKey: SIGNER_INSURER, weight: 1, hasSigned: false, signedAt: null, label: 'Insurer' },
],
transactionXdr: 'AAAAAgAAAAB8k0hvYl5yTHV0cmFuc2FjdGlvbkVudmVsb3Bl',
expiresAt: '2099-01-01T00:00:00.000Z',
status: 'pending',
};

/** Shipper's signature brings the combined weight to 3 and meets the threshold. */
export const thresholdMetApprovalFixture: CargoApproval = {
...pendingApprovalFixture,
signers: pendingApprovalFixture.signers.map((signer) =>
signer.publicKey === SIGNER_SHIPPER
? { ...signer, hasSigned: true, signedAt: '2026-09-28T09:00:00.000Z' }
: signer,
),
status: 'threshold_met',
};

export const expiredApprovalFixture: CargoApproval = {
...pendingApprovalFixture,
expiresAt: '2020-01-01T00:00:00.000Z',
status: 'expired',
};
96 changes: 96 additions & 0 deletions services/__tests__/cargoApprovalService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { AxiosError, AxiosHeaders } from 'axios';
import api from '@/lib/api';
import {
CargoApprovalServiceError,
cargoApprovalService,
} from '@/services/cargoApprovalService';
import {
SIGNER_SHIPPER,
pendingApprovalFixture,
thresholdMetApprovalFixture,
} from '@/hooks/__tests__/fixtures/cargoApprovalApiResponses';

jest.mock('@/lib/api', () => ({
__esModule: true,
default: { get: jest.fn(), post: jest.fn() },
}));

const mockGet = api.get as jest.Mock;
const mockPost = api.post as jest.Mock;

function axiosErrorWith(status: number, message: string): AxiosError {
const headers = new AxiosHeaders();
return new AxiosError('Request failed', 'ERR_BAD_REQUEST', { headers }, null, {
status,
statusText: 'Error',
headers,
config: { headers },
data: { message },
});
}

describe('cargoApprovalService', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('getApproval', () => {
it('reads the escrow approvals endpoint and unwraps the envelope', async () => {
mockGet.mockResolvedValue({ data: { success: true, data: pendingApprovalFixture } });

await expect(cargoApprovalService.getApproval('escrow-hv-001')).resolves.toEqual(
pendingApprovalFixture,
);
expect(mockGet).toHaveBeenCalledWith('/escrow/escrow-hv-001/approvals', {
signal: undefined,
});
});

it('encodes the escrow id in the path', async () => {
mockGet.mockResolvedValue({ data: { success: true, data: pendingApprovalFixture } });

await cargoApprovalService.getApproval('escrow/../1');

expect(mockGet).toHaveBeenCalledWith('/escrow/escrow%2F..%2F1/approvals', {
signal: undefined,
});
});

it('throws the API message for an unsuccessful envelope', async () => {
mockGet.mockResolvedValue({ data: { success: false, message: 'Escrow not found' } });

await expect(cargoApprovalService.getApproval('missing')).rejects.toThrow(
'Escrow not found',
);
});
});

describe('submitSignature', () => {
const request = { signerPublicKey: SIGNER_SHIPPER, signedTransactionXdr: 'signed-xdr' };

it('posts the signed envelope and returns the updated approval', async () => {
mockPost.mockResolvedValue({ data: { success: true, data: thresholdMetApprovalFixture } });

await expect(
cargoApprovalService.submitSignature('escrow-hv-001', request),
).resolves.toEqual(thresholdMetApprovalFixture);
expect(mockPost).toHaveBeenCalledWith('/escrow/escrow-hv-001/approvals/signatures', request);
});

it.each([
[409, 'already_signed'],
[410, 'expired'],
[403, 'unauthorized_signer'],
[500, 'unknown'],
])('maps HTTP %i to the %s error code', async (status, code) => {
mockPost.mockRejectedValue(axiosErrorWith(status, 'Rejected by escrow service'));

const error = await cargoApprovalService
.submitSignature('escrow-hv-001', request)
.catch((e: unknown) => e);

expect(error).toBeInstanceOf(CargoApprovalServiceError);
expect(error).toMatchObject({ code, status, message: 'Rejected by escrow service' });
});
});
});
85 changes: 85 additions & 0 deletions services/cargoApprovalService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { isAxiosError } from 'axios';
import api from '@/lib/api';
import type {
CargoApproval,
CargoApprovalApiResponse,
SubmitCargoSignatureRequest,
} from '@/types/cargoApproval';

/** Backend conflict states the approval flow reacts to. */
export type CargoApprovalErrorCode = 'already_signed' | 'expired' | 'unauthorized_signer' | 'unknown';

const STATUS_CODES: Record<number, CargoApprovalErrorCode> = {
403: 'unauthorized_signer',
409: 'already_signed',
410: 'expired',
};

export class CargoApprovalServiceError extends Error {
readonly status: number | null;
readonly code: CargoApprovalErrorCode;

constructor(message: string, status: number | null) {
super(message);
this.name = 'CargoApprovalServiceError';
this.status = status;
this.code = (status !== null && STATUS_CODES[status]) || 'unknown';
}
}

function toServiceError(error: unknown, fallback: string): CargoApprovalServiceError {
if (error instanceof CargoApprovalServiceError) return error;
if (isAxiosError<{ message?: string }>(error)) {
return new CargoApprovalServiceError(
error.response?.data?.message ?? fallback,
error.response?.status ?? null,
);
}
return new CargoApprovalServiceError(error instanceof Error ? error.message : fallback, null);
}

function unwrap<T>(response: CargoApprovalApiResponse<T>, fallback: string): T {
if (!response.success || response.data === undefined) {
throw new CargoApprovalServiceError(response.message ?? fallback, null);
}
return response.data;
}

const approvalsPath = (escrowId: string) => `/escrow/${encodeURIComponent(escrowId)}/approvals`;

/**
* cargoApprovalService — multi-signature approvals for high-value cargo escrows.
* Hooks call this; components never call this directly.
*/
export const cargoApprovalService = {
/** Loads the signer set, weights, threshold and approval window. */
async getApproval(escrowId: string, signal?: AbortSignal): Promise<CargoApproval> {
const fallback = 'Unable to load approval details. Please try again.';
try {
const { data } = await api.get<CargoApprovalApiResponse<CargoApproval>>(
approvalsPath(escrowId),
{ signal },
);
return unwrap(data, fallback);
} catch (error) {
throw toServiceError(error, fallback);
}
},

/** Submits a signed envelope and returns the updated approval state. */
async submitSignature(
escrowId: string,
request: SubmitCargoSignatureRequest,
): Promise<CargoApproval> {
const fallback = 'Unable to submit your signature. Please try again.';
try {
const { data } = await api.post<CargoApprovalApiResponse<CargoApproval>>(
`${approvalsPath(escrowId)}/signatures`,
request,
);
return unwrap(data, fallback);
} catch (error) {
throw toServiceError(error, fallback);
}
},
};
44 changes: 44 additions & 0 deletions types/cargoApproval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Multi-signature approval state for high-value cargo escrows, returned by
* GET /api/escrow/{escrowId}/approvals.
*
* Thresholds follow Stellar account semantics: each signer carries a weight and
* the approval is met once the combined weight of collected signatures reaches
* the threshold.
*/

export type CargoApprovalStatus = 'pending' | 'threshold_met' | 'executed' | 'expired' | 'cancelled';

export interface CargoApprovalSigner {
/** Stellar public key (G...) of the signer. */
publicKey: string;
weight: number;
hasSigned: boolean;
/** ISO 8601 timestamp of the signature, null while outstanding. */
signedAt: string | null;
/** Optional display label, e.g. "Fleet manager". */
label?: string;
}

export interface CargoApproval {
escrowId: string;
/** Combined signer weight required to release the escrow. */
threshold: number;
signers: CargoApprovalSigner[];
/** Base64 transaction envelope each signer signs. */
transactionXdr: string;
/** ISO 8601 end of the approval window. */
expiresAt: string;
status: CargoApprovalStatus;
}

export interface SubmitCargoSignatureRequest {
signerPublicKey: string;
signedTransactionXdr: string;
}

export interface CargoApprovalApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
Loading