diff --git a/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts b/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts new file mode 100644 index 0000000..4cecff6 --- /dev/null +++ b/hooks/__tests__/fixtures/cargoApprovalApiResponses.ts @@ -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', +}; diff --git a/services/__tests__/cargoApprovalService.test.ts b/services/__tests__/cargoApprovalService.test.ts new file mode 100644 index 0000000..ba91afb --- /dev/null +++ b/services/__tests__/cargoApprovalService.test.ts @@ -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' }); + }); + }); +}); diff --git a/services/cargoApprovalService.ts b/services/cargoApprovalService.ts new file mode 100644 index 0000000..01da503 --- /dev/null +++ b/services/cargoApprovalService.ts @@ -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 = { + 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(response: CargoApprovalApiResponse, 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 { + const fallback = 'Unable to load approval details. Please try again.'; + try { + const { data } = await api.get>( + 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 { + const fallback = 'Unable to submit your signature. Please try again.'; + try { + const { data } = await api.post>( + `${approvalsPath(escrowId)}/signatures`, + request, + ); + return unwrap(data, fallback); + } catch (error) { + throw toServiceError(error, fallback); + } + }, +}; diff --git a/types/cargoApproval.ts b/types/cargoApproval.ts new file mode 100644 index 0000000..1953c57 --- /dev/null +++ b/types/cargoApproval.ts @@ -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 { + success: boolean; + data?: T; + message?: string; +}