+ )}
+
+ {isEvidence && evidenceUrl && !evidenceIsPdf && (
+
+ )}
- {verified.imageUrl && (
+ {!isEvidence && verified?.imageUrl && (
{
- setVerified(null);
- setProofHash("");
- resetZoom();
- }}
+ onClick={resetViewer}
className="w-full px-4 py-2 rounded-lg border border-gray-700 text-sm font-medium hover:bg-gray-700"
>
- Verify Another
+ {isEvidence ? "Load Another" : "Verify Another"}
)}
diff --git a/src/components/StatusTimeline.tsx b/src/components/StatusTimeline.tsx
index 39c894cc..afec8d80 100644
--- a/src/components/StatusTimeline.tsx
+++ b/src/components/StatusTimeline.tsx
@@ -3,6 +3,26 @@
import type { Invoice } from "@stellar-split/sdk";
import RelativeTime from "@/components/ui/RelativeTime";
+export interface MilestoneRecord {
+ id: string;
+ title: string;
+ conditionMet?: boolean;
+ evidenceSubmitted?: boolean;
+ released?: boolean;
+ releasedAt?: number | null;
+ evidenceAt?: number | null;
+ conditionMetAt?: number | null;
+}
+
+export interface DisputeRecord {
+ id: string;
+ status: "Open" | "Evidence" | "Resolved" | "Enforced" | string;
+ openedAt?: number | null;
+ evidenceAt?: number | null;
+ resolvedAt?: number | null;
+ enforcedAt?: number | null;
+}
+
interface TimelineEvent {
key: string;
icon: string;
@@ -15,7 +35,12 @@ function truncate(addr: string) {
return addr.length > 12 ? `${addr.slice(0, 6)}…${addr.slice(-4)}` : addr;
}
-function buildEvents(invoice: Invoice, total: bigint): TimelineEvent[] {
+function buildEvents(
+ invoice: Invoice,
+ total: bigint,
+ milestones: MilestoneRecord[] = [],
+ disputes: DisputeRecord[] = [],
+): TimelineEvent[] {
const events: TimelineEvent[] = [];
// Created — use deadline as a proxy anchor; SDK Invoice has no createdAt
@@ -55,6 +80,68 @@ function buildEvents(invoice: Invoice, total: bigint): TimelineEvent[] {
});
}
+ // Milestone condition events
+ for (const m of milestones) {
+ if (m.conditionMet) {
+ events.push({
+ key: `milestone-${m.id}-condition`,
+ icon: "🎯",
+ label: `Condition Met: ${m.title}`,
+ timestamp: m.conditionMetAt ?? null,
+ });
+ }
+ if (m.evidenceSubmitted) {
+ events.push({
+ key: `milestone-${m.id}-evidence`,
+ icon: "📎",
+ label: `Evidence Submitted: ${m.title}`,
+ timestamp: m.evidenceAt ?? null,
+ });
+ }
+ if (m.released) {
+ events.push({
+ key: `milestone-${m.id}-released`,
+ icon: "🔓",
+ label: `Milestone Released: ${m.title}`,
+ timestamp: m.releasedAt ?? null,
+ });
+ }
+ }
+
+ // Dispute lifecycle events
+ for (const d of disputes) {
+ events.push({
+ key: `dispute-${d.id}-opened`,
+ icon: "⚠️",
+ label: "Dispute Opened",
+ timestamp: d.openedAt ?? null,
+ });
+ if (d.evidenceAt != null || d.status === "Evidence" || d.status === "Resolved" || d.status === "Enforced") {
+ events.push({
+ key: `dispute-${d.id}-evidence`,
+ icon: "📑",
+ label: "Dispute Evidence Submitted",
+ timestamp: d.evidenceAt ?? null,
+ });
+ }
+ if (d.status === "Resolved" || d.status === "Enforced") {
+ events.push({
+ key: `dispute-${d.id}-resolved`,
+ icon: "⚖️",
+ label: "Dispute Resolved",
+ timestamp: d.resolvedAt ?? null,
+ });
+ }
+ if (d.status === "Enforced") {
+ events.push({
+ key: `dispute-${d.id}-enforced`,
+ icon: "🔒",
+ label: "Resolution Enforced",
+ timestamp: d.enforcedAt ?? null,
+ });
+ }
+ }
+
if (invoice.status === "Released") {
events.push({
key: "released",
@@ -80,15 +167,22 @@ function buildEvents(invoice: Invoice, total: bigint): TimelineEvent[] {
interface Props {
invoice: Invoice;
total: bigint;
+ milestones?: MilestoneRecord[];
+ disputes?: DisputeRecord[];
}
-export default function StatusTimeline({ invoice, total }: Props) {
- const events = buildEvents(invoice, total);
+export default function StatusTimeline({ invoice, total, milestones = [], disputes = [] }: Props) {
+ const events = buildEvents(invoice, total, milestones, disputes);
+
+ const hasActiveDispute = disputes.some(
+ (d) => d.status !== "Resolved" && d.status !== "Enforced",
+ );
// ── Mobile: horizontal stepper ─────────────────────────────────────────────
const STEPS = ["Created", "Partially Funded", "Funded", "Released / Refunded"] as const;
function getActiveStep(): number {
+ if (hasActiveDispute) return 4;
if (invoice.status === "Released" || invoice.status === "Refunded") return 3;
if (total > 0n && invoice.funded >= total) return 2;
if (invoice.funded > 0n) return 1;
@@ -96,23 +190,25 @@ export default function StatusTimeline({ invoice, total }: Props) {
}
const active = getActiveStep();
+ const steps = hasActiveDispute ? [...STEPS, "Disputed"] : STEPS;
return (
<>
{/* Mobile: compact horizontal stepper */}
- {STEPS.map((step, i) => {
+ {steps.map((step, i) => {
const done = i < active;
const current = i === active;
+ const isDisputed = step === "Disputed";
const label = i === 3 && invoice.status !== "Pending" ? invoice.status : step;
return (
- {i > 0 &&
}
-
- {i < STEPS.length - 1 &&
}
+ {i > 0 &&
}
+
+ {i < steps.length - 1 &&
}
-
+
{label}
diff --git a/src/lib/arbitrators.ts b/src/lib/arbitrators.ts
index e1874b78..b44686fa 100644
--- a/src/lib/arbitrators.ts
+++ b/src/lib/arbitrators.ts
@@ -51,3 +51,548 @@ export function searchArbitrators(query: string): Arbitrator[] {
export function getArbitratorByAddress(address: string): Arbitrator | undefined {
return getArbitrators().find((a) => a.address === address);
}
+
+// ---------------------------------------------------------------------------
+// Dispute resolution system
+// ---------------------------------------------------------------------------
+
+export type DisputeStatus =
+ | "open"
+ | "evidence"
+ | "arbitration"
+ | "resolved"
+ | "enforced";
+
+export type DisputeParty = "initiator" | "respondent";
+
+export interface DisputeEvidence {
+ id: string;
+ submittedBy: string;
+ party: DisputeParty;
+ description: string;
+ /** Optional reference (URL, hash, or free-form pointer) to the evidence. */
+ reference?: string;
+ submittedAt: string;
+}
+
+export type DisputeTimelineEventType =
+ | "created"
+ | "evidence_submitted"
+ | "arbitrator_assigned"
+ | "vote_recorded"
+ | "resolved"
+ | "enforced"
+ | "status_changed";
+
+export interface DisputeTimelineEvent {
+ id: string;
+ type: DisputeTimelineEventType;
+ actor: string;
+ message: string;
+ at: string;
+ metadata?: Record
;
+}
+
+export interface ArbitratorVote {
+ arbitrator: string;
+ /** Outcome the arbitrator voted for. */
+ outcome: DisputeOutcome;
+ /** Optional rationale for the vote. */
+ rationale?: string;
+ votedAt: string;
+}
+
+export type DisputeOutcome =
+ | "favor_initiator"
+ | "favor_respondent"
+ | "split"
+ | "dismissed";
+
+export interface DisputeResolution {
+ outcome: DisputeOutcome;
+ /** Human readable summary of the decision. */
+ summary: string;
+ /** Optional amount (in the invoice currency) to be awarded. */
+ awardedAmount?: number;
+ resolvedBy: string;
+ resolvedAt: string;
+ enforced: boolean;
+ enforcedAt?: string;
+ enforcedBy?: string;
+}
+
+export interface DisputeRecord {
+ id: string;
+ invoiceId: string;
+ initiator: string;
+ respondent: string;
+ reason: string;
+ description: string;
+ status: DisputeStatus;
+ assignedArbitrators: string[];
+ evidence: DisputeEvidence[];
+ votes: ArbitratorVote[];
+ timeline: DisputeTimelineEvent[];
+ resolution?: DisputeResolution;
+ createdAt: string;
+ updatedAt: string;
+}
+
+const DISPUTES_STORAGE_KEY = "stellarsplit_disputes";
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+function generateId(prefix: string): string {
+ const rand = Math.random().toString(36).slice(2, 10);
+ return `${prefix}_${Date.now().toString(36)}_${rand}`;
+}
+
+function isBrowser(): boolean {
+ return typeof window !== "undefined";
+}
+
+function readAllDisputes(): DisputeRecord[] {
+ if (!isBrowser()) return [];
+ try {
+ const raw = localStorage.getItem(DISPUTES_STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as DisputeRecord[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeAllDisputes(disputes: DisputeRecord[]): void {
+ if (!isBrowser()) return;
+ try {
+ localStorage.setItem(DISPUTES_STORAGE_KEY, JSON.stringify(disputes));
+ } catch {
+ // Ignore persistence failures (quota, private mode, etc.)
+ }
+}
+
+function appendTimeline(
+ dispute: DisputeRecord,
+ type: DisputeTimelineEventType,
+ actor: string,
+ message: string,
+ metadata?: Record,
+): void {
+ dispute.timeline.push({
+ id: generateId("evt"),
+ type,
+ actor,
+ message,
+ at: nowIso(),
+ metadata,
+ });
+}
+
+function touch(dispute: DisputeRecord): void {
+ dispute.updatedAt = nowIso();
+}
+
+function saveDispute(dispute: DisputeRecord): DisputeRecord {
+ const all = readAllDisputes();
+ const idx = all.findIndex((d) => d.id === dispute.id);
+ if (idx >= 0) {
+ all[idx] = dispute;
+ } else {
+ all.push(dispute);
+ }
+ writeAllDisputes(all);
+ return dispute;
+}
+
+export function getDisputes(): DisputeRecord[] {
+ return readAllDisputes();
+}
+
+export function getDisputeById(id: string): DisputeRecord | undefined {
+ return readAllDisputes().find((d) => d.id === id);
+}
+
+export function getDisputesByInvoice(invoiceId: string): DisputeRecord[] {
+ return readAllDisputes().filter((d) => d.invoiceId === invoiceId);
+}
+
+export function getDisputesByParty(address: string): DisputeRecord[] {
+ return readAllDisputes().filter(
+ (d) => d.initiator === address || d.respondent === address,
+ );
+}
+
+export interface CreateDisputeInput {
+ invoiceId: string;
+ initiator: string;
+ respondent: string;
+ reason: string;
+ description: string;
+}
+
+export function createDispute(input: CreateDisputeInput): DisputeRecord {
+ const invoiceId = input.invoiceId?.trim();
+ const initiator = input.initiator?.trim();
+ const respondent = input.respondent?.trim();
+ const reason = input.reason?.trim();
+ const description = input.description?.trim();
+
+ if (!invoiceId) throw new Error("Invoice ID is required");
+ if (!initiator) throw new Error("Initiator address is required");
+ if (!respondent) throw new Error("Respondent address is required");
+ if (initiator === respondent) {
+ throw new Error("Initiator and respondent must be different parties");
+ }
+ if (!reason) throw new Error("Dispute reason is required");
+ if (!description) throw new Error("Dispute description is required");
+
+ const existing = readAllDisputes().find(
+ (d) =>
+ d.invoiceId === invoiceId &&
+ d.status !== "resolved" &&
+ d.status !== "enforced",
+ );
+ if (existing) {
+ throw new Error("An active dispute already exists for this invoice");
+ }
+
+ const createdAt = nowIso();
+ const dispute: DisputeRecord = {
+ id: generateId("dsp"),
+ invoiceId,
+ initiator,
+ respondent,
+ reason,
+ description,
+ status: "open",
+ assignedArbitrators: [],
+ evidence: [],
+ votes: [],
+ timeline: [],
+ createdAt,
+ updatedAt: createdAt,
+ };
+
+ appendTimeline(
+ dispute,
+ "created",
+ initiator,
+ `Dispute opened for invoice ${invoiceId}: ${reason}`,
+ );
+
+ return saveDispute(dispute);
+}
+
+export interface SubmitEvidenceInput {
+ disputeId: string;
+ submittedBy: string;
+ description: string;
+ reference?: string;
+}
+
+export function submitEvidence(input: SubmitEvidenceInput): DisputeRecord {
+ const dispute = getDisputeById(input.disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+ if (dispute.status === "resolved" || dispute.status === "enforced") {
+ throw new Error("Cannot submit evidence to a closed dispute");
+ }
+
+ const submittedBy = input.submittedBy?.trim();
+ if (!submittedBy) throw new Error("Submitter address is required");
+
+ let party: DisputeParty;
+ if (submittedBy === dispute.initiator) {
+ party = "initiator";
+ } else if (submittedBy === dispute.respondent) {
+ party = "respondent";
+ } else {
+ throw new Error("Only the involved parties may submit evidence");
+ }
+
+ const description = input.description?.trim();
+ if (!description) throw new Error("Evidence description is required");
+
+ const evidence: DisputeEvidence = {
+ id: generateId("evd"),
+ submittedBy,
+ party,
+ description,
+ reference: input.reference?.trim() || undefined,
+ submittedAt: nowIso(),
+ };
+
+ dispute.evidence.push(evidence);
+
+ if (dispute.status === "open") {
+ dispute.status = "evidence";
+ appendTimeline(
+ dispute,
+ "status_changed",
+ submittedBy,
+ "Dispute moved to evidence phase",
+ { status: "evidence" },
+ );
+ }
+
+ appendTimeline(
+ dispute,
+ "evidence_submitted",
+ submittedBy,
+ `Evidence submitted by ${party}`,
+ { evidenceId: evidence.id },
+ );
+
+ touch(dispute);
+ return saveDispute(dispute);
+}
+
+export function assignArbitrator(
+ disputeId: string,
+ arbitratorAddress: string,
+ actor?: string,
+): DisputeRecord {
+ const dispute = getDisputeById(disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+ if (dispute.status === "resolved" || dispute.status === "enforced") {
+ throw new Error("Cannot assign arbitrators to a closed dispute");
+ }
+
+ const address = arbitratorAddress?.trim();
+ if (!address) throw new Error("Arbitrator address is required");
+
+ const known = getArbitratorByAddress(address);
+ if (!known) throw new Error("Arbitrator is not in the registry");
+
+ if (dispute.assignedArbitrators.includes(address)) {
+ throw new Error("Arbitrator is already assigned to this dispute");
+ }
+
+ dispute.assignedArbitrators.push(address);
+
+ if (dispute.status === "open" || dispute.status === "evidence") {
+ dispute.status = "arbitration";
+ appendTimeline(
+ dispute,
+ "status_changed",
+ actor ?? address,
+ "Dispute moved to arbitration phase",
+ { status: "arbitration" },
+ );
+ }
+
+ appendTimeline(
+ dispute,
+ "arbitrator_assigned",
+ actor ?? address,
+ `Arbitrator ${known.name} assigned`,
+ { arbitrator: address },
+ );
+
+ touch(dispute);
+ return saveDispute(dispute);
+}
+
+/**
+ * Assigns a set of arbitrators to a dispute. Skips arbitrators that are
+ * already assigned and validates each against the registry.
+ */
+export function assignArbitrators(
+ disputeId: string,
+ arbitratorAddresses: string[],
+ actor?: string,
+): DisputeRecord {
+ if (!Array.isArray(arbitratorAddresses) || arbitratorAddresses.length === 0) {
+ throw new Error("At least one arbitrator address is required");
+ }
+
+ let dispute = getDisputeById(disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+
+ for (const address of arbitratorAddresses) {
+ const trimmed = address?.trim();
+ if (!trimmed) continue;
+ if (dispute.assignedArbitrators.includes(trimmed)) continue;
+ dispute = assignArbitrator(disputeId, trimmed, actor);
+ }
+
+ return dispute;
+}
+
+export function getAssignedArbitrators(disputeId: string): Arbitrator[] {
+ const dispute = getDisputeById(disputeId);
+ if (!dispute) return [];
+ return dispute.assignedArbitrators
+ .map((address) => getArbitratorByAddress(address))
+ .filter((a): a is Arbitrator => Boolean(a));
+}
+
+export interface RecordVoteInput {
+ disputeId: string;
+ arbitrator: string;
+ outcome: DisputeOutcome;
+ rationale?: string;
+}
+
+export function recordVote(input: RecordVoteInput): DisputeRecord {
+ const dispute = getDisputeById(input.disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+ if (dispute.status === "resolved" || dispute.status === "enforced") {
+ throw new Error("Cannot vote on a closed dispute");
+ }
+
+ const arbitrator = input.arbitrator?.trim();
+ if (!arbitrator) throw new Error("Arbitrator address is required");
+ if (!dispute.assignedArbitrators.includes(arbitrator)) {
+ throw new Error("Only assigned arbitrators may vote");
+ }
+ if (dispute.votes.some((v) => v.arbitrator === arbitrator)) {
+ throw new Error("Arbitrator has already voted on this dispute");
+ }
+
+ const validOutcomes: DisputeOutcome[] = [
+ "favor_initiator",
+ "favor_respondent",
+ "split",
+ "dismissed",
+ ];
+ if (!validOutcomes.includes(input.outcome)) {
+ throw new Error("Invalid vote outcome");
+ }
+
+ dispute.votes.push({
+ arbitrator,
+ outcome: input.outcome,
+ rationale: input.rationale?.trim() || undefined,
+ votedAt: nowIso(),
+ });
+
+ appendTimeline(
+ dispute,
+ "vote_recorded",
+ arbitrator,
+ `Arbitrator voted: ${input.outcome}`,
+ { outcome: input.outcome },
+ );
+
+ touch(dispute);
+ return saveDispute(dispute);
+}
+
+export interface ResolveDisputeInput {
+ disputeId: string;
+ outcome: DisputeOutcome;
+ summary: string;
+ awardedAmount?: number;
+ resolvedBy: string;
+}
+
+export function resolveDispute(input: ResolveDisputeInput): DisputeRecord {
+ const dispute = getDisputeById(input.disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+ if (dispute.status === "resolved" || dispute.status === "enforced") {
+ throw new Error("Dispute is already resolved");
+ }
+ if (dispute.assignedArbitrators.length === 0) {
+ throw new Error("Cannot resolve a dispute without assigned arbitrators");
+ }
+ if (dispute.votes.length === 0) {
+ throw new Error("Cannot resolve a dispute without arbitrator votes");
+ }
+
+ const summary = input.summary?.trim();
+ if (!summary) throw new Error("Resolution summary is required");
+
+ const resolvedBy = input.resolvedBy?.trim();
+ if (!resolvedBy) throw new Error("Resolver address is required");
+ if (!dispute.assignedArbitrators.includes(resolvedBy)) {
+ throw new Error("Only assigned arbitrators may resolve the dispute");
+ }
+
+ if (
+ input.awardedAmount !== undefined &&
+ (Number.isNaN(input.awardedAmount) || input.awardedAmount < 0)
+ ) {
+ throw new Error("Awarded amount must be a non-negative number");
+ }
+
+ dispute.resolution = {
+ outcome: input.outcome,
+ summary,
+ awardedAmount: input.awardedAmount,
+ resolvedBy,
+ resolvedAt: nowIso(),
+ enforced: false,
+ };
+ dispute.status = "resolved";
+
+ appendTimeline(
+ dispute,
+ "resolved",
+ resolvedBy,
+ `Dispute resolved: ${input.outcome}`,
+ { outcome: input.outcome, awardedAmount: input.awardedAmount },
+ );
+
+ touch(dispute);
+ return saveDispute(dispute);
+}
+
+export interface EnforceResolutionInput {
+ disputeId: string;
+ enforcedBy: string;
+}
+
+export function enforceResolution(
+ input: EnforceResolutionInput,
+): DisputeRecord {
+ const dispute = getDisputeById(input.disputeId);
+ if (!dispute) throw new Error("Dispute not found");
+ if (dispute.status !== "resolved" || !dispute.resolution) {
+ throw new Error("Cannot enforce a dispute before it is resolved");
+ }
+ if (dispute.resolution.enforced) {
+ throw new Error("Resolution has already been enforced");
+ }
+
+ const enforcedBy = input.enforcedBy?.trim();
+ if (!enforcedBy) throw new Error("Enforcer address is required");
+
+ const enforcedAt = nowIso();
+ dispute.resolution.enforced = true;
+ dispute.resolution.enforcedAt = enforcedAt;
+ dispute.resolution.enforcedBy = enforcedBy;
+ dispute.status = "enforced";
+
+ appendTimeline(
+ dispute,
+ "enforced",
+ enforcedBy,
+ "Resolution enforced",
+ { outcome: dispute.resolution.outcome },
+ );
+
+ touch(dispute);
+ return saveDispute(dispute);
+}
+
+export function getDisputeTimeline(disputeId: string): DisputeTimelineEvent[] {
+ const dispute = getDisputeById(disputeId);
+ return dispute ? [...dispute.timeline] : [];
+}
+
+export function getDisputeEvidence(disputeId: string): DisputeEvidence[] {
+ const dispute = getDisputeById(disputeId);
+ return dispute ? [...dispute.evidence] : [];
+}
+
+export function getDisputeVotes(disputeId: string): ArbitratorVote[] {
+ const dispute = getDisputeById(disputeId);
+ return dispute ? [...dispute.votes] : [];
+}
+
+export function isDisputeActive(dispute: DisputeRecord): boolean {
+ return dispute.status !== "resolved" && dispute.status !== "enforced";
+}
\ No newline at end of file
diff --git a/src/lib/invoiceMilestones.ts b/src/lib/invoiceMilestones.ts
index 6e9674c3..b3df0afb 100644
--- a/src/lib/invoiceMilestones.ts
+++ b/src/lib/invoiceMilestones.ts
@@ -24,3 +24,438 @@ export function newestMilestone(raised: number, target: number): InvoiceMileston
const reached = milestonesFor(raised, target).filter((m) => m.reached);
return reached.length ? reached[reached.length - 1] : null;
}
+
+// ---------------------------------------------------------------------------
+// Conditional milestone releases
+// ---------------------------------------------------------------------------
+
+export type ConditionType = "deliverable" | "approval" | "evidence";
+
+export type ConditionStatus =
+ | "pending"
+ | "submitted"
+ | "approved"
+ | "verified"
+ | "rejected";
+
+export interface ApprovalRecord {
+ id: string;
+ approver: string;
+ approved: boolean;
+ note?: string;
+ timestamp: number;
+}
+
+export interface EvidenceRecord {
+ id: string;
+ fileName: string;
+ mimeType?: string;
+ url?: string;
+ uploadedBy: string;
+ timestamp: number;
+ verified: boolean;
+ verifiedBy?: string;
+ verifiedAt?: number;
+}
+
+export interface MilestoneCondition {
+ id: string;
+ type: ConditionType;
+ description: string;
+ status: ConditionStatus;
+ required: boolean;
+ approvals: ApprovalRecord[];
+ evidence: EvidenceRecord[];
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface MilestoneState {
+ id: string;
+ percent: number;
+ label: string;
+ conditions: MilestoneCondition[];
+ released: boolean;
+ releasedAt?: number;
+}
+
+export interface ConditionValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+
+export interface ReleaseGateResult {
+ canRelease: boolean;
+ reasons: string[];
+ unmetConditions: MilestoneCondition[];
+}
+
+let idCounter = 0;
+
+function generateId(prefix: string): string {
+ idCounter += 1;
+ return `${prefix}_${Date.now().toString(36)}_${idCounter.toString(36)}`;
+}
+
+function now(): number {
+ return Date.now();
+}
+
+// ---------------------------------------------------------------------------
+// Pure validation functions
+// ---------------------------------------------------------------------------
+
+export function validateCondition(condition: MilestoneCondition): ConditionValidationResult {
+ const errors: string[] = [];
+
+ if (!condition.id) errors.push("Condition id is required");
+ if (!condition.description || !condition.description.trim()) {
+ errors.push("Condition description is required");
+ }
+ if (condition.type !== "deliverable" && condition.type !== "approval" && condition.type !== "evidence") {
+ errors.push(`Unknown condition type: ${String(condition.type)}`);
+ }
+
+ if (condition.type === "approval") {
+ if (condition.status === "approved" || condition.status === "verified") {
+ const hasApproval = condition.approvals.some((a) => a.approved);
+ if (!hasApproval) {
+ errors.push("Approval condition requires at least one approving record");
+ }
+ }
+ }
+
+ if (condition.type === "evidence") {
+ if (condition.status === "verified") {
+ const hasVerifiedEvidence = condition.evidence.some((e) => e.verified);
+ if (!hasVerifiedEvidence) {
+ errors.push("Evidence condition requires at least one verified evidence record");
+ }
+ }
+ }
+
+ return { valid: errors.length === 0, errors };
+}
+
+export function isConditionSatisfied(condition: MilestoneCondition): boolean {
+ if (!condition.required) return true;
+
+ switch (condition.type) {
+ case "deliverable":
+ return condition.status === "verified" || condition.status === "approved";
+ case "approval":
+ return (
+ (condition.status === "approved" || condition.status === "verified") &&
+ condition.approvals.some((a) => a.approved)
+ );
+ case "evidence":
+ return (
+ (condition.status === "verified" || condition.status === "approved") &&
+ condition.evidence.some((e) => e.verified)
+ );
+ default:
+ return false;
+ }
+}
+
+export function evaluateReleaseGate(milestone: MilestoneState): ReleaseGateResult {
+ const unmetConditions = milestone.conditions.filter((c) => !isConditionSatisfied(c));
+ const reasons: string[] = [];
+
+ if (milestone.released) {
+ reasons.push("Milestone has already been released");
+ }
+
+ for (const condition of unmetConditions) {
+ reasons.push(`Condition not satisfied: ${condition.description} (${condition.type})`);
+ }
+
+ return {
+ canRelease: reasons.length === 0,
+ reasons,
+ unmetConditions,
+ };
+}
+
+export function canReleaseMilestone(milestone: MilestoneState): boolean {
+ return evaluateReleaseGate(milestone).canRelease;
+}
+
+// ---------------------------------------------------------------------------
+// Persistence helpers (localStorage-backed)
+// ---------------------------------------------------------------------------
+
+const STORAGE_KEY = "invoiceMilestones.v1";
+
+function getStorage(): Storage | null {
+ try {
+ if (typeof localStorage !== "undefined") return localStorage;
+ } catch {
+ // Access to localStorage may throw in restricted environments.
+ }
+ return null;
+}
+
+function readAll(): Record {
+ const storage = getStorage();
+ if (!storage) return {};
+ try {
+ const raw = storage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === "object") return parsed as Record;
+ return {};
+ } catch {
+ return {};
+ }
+}
+
+function writeAll(data: Record): void {
+ const storage = getStorage();
+ if (!storage) return;
+ try {
+ storage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch {
+ // Ignore quota / serialization errors.
+ }
+}
+
+export function getMilestones(invoiceId: string): MilestoneState[] {
+ const all = readAll();
+ return all[invoiceId] ? all[invoiceId].map((m) => ({ ...m })) : [];
+}
+
+export function saveMilestones(invoiceId: string, milestones: MilestoneState[]): void {
+ const all = readAll();
+ all[invoiceId] = milestones;
+ writeAll(all);
+}
+
+function updateMilestone(
+ invoiceId: string,
+ milestoneId: string,
+ updater: (milestone: MilestoneState) => MilestoneState,
+): MilestoneState | null {
+ const milestones = getMilestones(invoiceId);
+ const index = milestones.findIndex((m) => m.id === milestoneId);
+ if (index === -1) return null;
+ const updated = updater(milestones[index]);
+ milestones[index] = updated;
+ saveMilestones(invoiceId, milestones);
+ return updated;
+}
+
+function updateCondition(
+ invoiceId: string,
+ milestoneId: string,
+ conditionId: string,
+ updater: (condition: MilestoneCondition) => MilestoneCondition,
+): MilestoneCondition | null {
+ let result: MilestoneCondition | null = null;
+ updateMilestone(invoiceId, milestoneId, (milestone) => {
+ const conditions = milestone.conditions.map((c) => {
+ if (c.id !== conditionId) return c;
+ const next = updater(c);
+ result = next;
+ return next;
+ });
+ return { ...milestone, conditions };
+ });
+ return result;
+}
+
+export function createMilestone(
+ invoiceId: string,
+ percent: number,
+ label?: string,
+): MilestoneState {
+ const milestone: MilestoneState = {
+ id: generateId("ms"),
+ percent,
+ label: label ?? (percent === 100 ? "Fully funded" : `${percent}% funded`),
+ conditions: [],
+ released: false,
+ };
+ const existing = getMilestones(invoiceId);
+ existing.push(milestone);
+ saveMilestones(invoiceId, existing);
+ return milestone;
+}
+
+export function addCondition(
+ invoiceId: string,
+ milestoneId: string,
+ input: {
+ type: ConditionType;
+ description: string;
+ required?: boolean;
+ },
+): MilestoneCondition | null {
+ const condition: MilestoneCondition = {
+ id: generateId("cond"),
+ type: input.type,
+ description: input.description,
+ status: "pending",
+ required: input.required ?? true,
+ approvals: [],
+ evidence: [],
+ createdAt: now(),
+ updatedAt: now(),
+ };
+
+ const updated = updateMilestone(invoiceId, milestoneId, (milestone) => ({
+ ...milestone,
+ conditions: [...milestone.conditions, condition],
+ }));
+
+ return updated ? condition : null;
+}
+
+export function submitEvidence(
+ invoiceId: string,
+ milestoneId: string,
+ conditionId: string,
+ input: {
+ fileName: string;
+ mimeType?: string;
+ url?: string;
+ uploadedBy: string;
+ },
+): EvidenceRecord | null {
+ const evidence: EvidenceRecord = {
+ id: generateId("ev"),
+ fileName: input.fileName,
+ mimeType: input.mimeType,
+ url: input.url,
+ uploadedBy: input.uploadedBy,
+ timestamp: now(),
+ verified: false,
+ };
+
+ const updated = updateCondition(invoiceId, milestoneId, conditionId, (condition) => ({
+ ...condition,
+ evidence: [...condition.evidence, evidence],
+ status: condition.status === "pending" ? "submitted" : condition.status,
+ updatedAt: now(),
+ }));
+
+ return updated ? evidence : null;
+}
+
+export function verifyEvidence(
+ invoiceId: string,
+ milestoneId: string,
+ conditionId: string,
+ evidenceId: string,
+ verifiedBy: string,
+): EvidenceRecord | null {
+ let result: EvidenceRecord | null = null;
+
+ updateCondition(invoiceId, milestoneId, conditionId, (condition) => {
+ const evidence = condition.evidence.map((e) => {
+ if (e.id !== evidenceId) return e;
+ const next: EvidenceRecord = {
+ ...e,
+ verified: true,
+ verifiedBy,
+ verifiedAt: now(),
+ };
+ result = next;
+ return next;
+ });
+ return { ...condition, evidence, updatedAt: now() };
+ });
+
+ return result;
+}
+
+export function approveCondition(
+ invoiceId: string,
+ milestoneId: string,
+ conditionId: string,
+ input: {
+ approver: string;
+ approved: boolean;
+ note?: string;
+ },
+): ApprovalRecord | null {
+ const approval: ApprovalRecord = {
+ id: generateId("appr"),
+ approver: input.approver,
+ approved: input.approved,
+ note: input.note,
+ timestamp: now(),
+ };
+
+ const updated = updateCondition(invoiceId, milestoneId, conditionId, (condition) => {
+ const approvals = [...condition.approvals, approval];
+ let status: ConditionStatus = condition.status;
+ if (input.approved) {
+ status = "approved";
+ } else {
+ status = "rejected";
+ }
+ return { ...condition, approvals, status, updatedAt: now() };
+ });
+
+ return updated ? approval : null;
+}
+
+export function verifyCondition(
+ invoiceId: string,
+ milestoneId: string,
+ conditionId: string,
+): MilestoneCondition | null {
+ return updateCondition(invoiceId, milestoneId, conditionId, (condition) => {
+ const validation = validateCondition({ ...condition, status: "verified" });
+ if (!validation.valid) {
+ return condition;
+ }
+ return { ...condition, status: "verified", updatedAt: now() };
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Release gate
+// ---------------------------------------------------------------------------
+
+export class MilestoneReleaseError extends Error {
+ readonly reasons: string[];
+ readonly unmetConditions: MilestoneCondition[];
+
+ constructor(reasons: string[], unmetConditions: MilestoneCondition[]) {
+ super(`Cannot release milestone: ${reasons.join("; ")}`);
+ this.name = "MilestoneReleaseError";
+ this.reasons = reasons;
+ this.unmetConditions = unmetConditions;
+ }
+}
+
+export function releaseMilestone(
+ invoiceId: string,
+ milestoneId: string,
+): MilestoneState {
+ const milestones = getMilestones(invoiceId);
+ const milestone = milestones.find((m) => m.id === milestoneId);
+
+ if (!milestone) {
+ throw new MilestoneReleaseError(["Milestone not found"], []);
+ }
+
+ const gate = evaluateReleaseGate(milestone);
+ if (!gate.canRelease) {
+ throw new MilestoneReleaseError(gate.reasons, gate.unmetConditions);
+ }
+
+ const updated = updateMilestone(invoiceId, milestoneId, (m) => ({
+ ...m,
+ released: true,
+ releasedAt: now(),
+ }));
+
+ if (!updated) {
+ throw new MilestoneReleaseError(["Milestone not found"], []);
+ }
+
+ return updated;
+}
\ No newline at end of file
diff --git a/src/lib/invoiceStatus.ts b/src/lib/invoiceStatus.ts
index 7a69a549..b634cd52 100644
--- a/src/lib/invoiceStatus.ts
+++ b/src/lib/invoiceStatus.ts
@@ -1,3 +1,9 @@
+import {
+ areMilestoneConditionsSatisfied,
+ getInvoiceMilestones,
+} from "./invoiceMilestones";
+import { getActiveDispute } from "./invoiceDisputes";
+
export type InvoiceStatus =
| "Pending"
| "Active"
@@ -67,3 +73,115 @@ export const STATUS_CONFIG: Record = {
description: "Invoice has expired and can no longer be paid or modified",
},
};
+
+/**
+ * Allowed status transitions for the invoice state machine.
+ * A transition is only valid if the target status is listed for the
+ * current status.
+ */
+export const STATUS_TRANSITIONS: Record = {
+ Pending: ["Active", "Expired", "Archived"],
+ Active: ["Funded", "Expired", "Archived"],
+ Funded: ["Released", "Refunded", "Disputed", "Frozen"],
+ Released: ["Archived"],
+ Refunded: ["Archived"],
+ Disputed: ["Funded", "Released", "Refunded", "Frozen", "Archived"],
+ Frozen: ["Funded", "Disputed", "Archived"],
+ Archived: [],
+ Expired: ["Archived"],
+};
+
+export interface ReleaseCheckResult {
+ allowed: boolean;
+ reasons: string[];
+}
+
+/**
+ * Returns true if the transition from `from` to `to` is permitted by the
+ * invoice state machine.
+ */
+export function canTransition(
+ from: InvoiceStatus,
+ to: InvoiceStatus,
+): boolean {
+ return STATUS_TRANSITIONS[from]?.includes(to) ?? false;
+}
+
+/**
+ * Determines whether an invoice's funds may be released.
+ *
+ * Release is blocked when:
+ * - the invoice has an unresolved dispute,
+ * - the invoice is frozen, archived or expired,
+ * - any milestone condition has not been satisfied.
+ */
+export function canReleaseInvoice(invoiceId: string): boolean {
+ return getReleaseCheck(invoiceId).allowed;
+}
+
+/**
+ * Detailed release check that reports every reason release is blocked.
+ */
+export function getReleaseCheck(invoiceId: string): ReleaseCheckResult {
+ const reasons: string[] = [];
+
+ const dispute = getActiveDispute(invoiceId);
+ if (dispute) {
+ reasons.push("Invoice has an unresolved dispute");
+ }
+
+ const milestones = getInvoiceMilestones(invoiceId);
+ if (milestones.length > 0 && !areMilestoneConditionsSatisfied(invoiceId)) {
+ reasons.push("Milestone conditions have not been satisfied");
+ }
+
+ return { allowed: reasons.length === 0, reasons };
+}
+
+/**
+ * Convenience helper used by the release flow to short-circuit release
+ * attempts. Equivalent to `!canReleaseInvoice(invoiceId)`.
+ */
+export function isReleaseBlocked(invoiceId: string): boolean {
+ return !canReleaseInvoice(invoiceId);
+}
+
+/**
+ * Validates and applies a transition to the Disputed status.
+ * Throws if the transition is not permitted by the state machine.
+ */
+export function transitionToDisputed(
+ currentStatus: InvoiceStatus,
+): InvoiceStatus {
+ if (!canTransition(currentStatus, "Disputed")) {
+ throw new Error(
+ `Invalid status transition: ${currentStatus} -> Disputed`,
+ );
+ }
+ return "Disputed";
+}
+
+/**
+ * Validates and applies a transition to the Released status.
+ * Enforces dispute resolution and milestone conditions before release.
+ * Throws if the transition is not permitted or release is blocked.
+ */
+export function transitionToReleased(
+ invoiceId: string,
+ currentStatus: InvoiceStatus,
+): InvoiceStatus {
+ if (!canTransition(currentStatus, "Released")) {
+ throw new Error(
+ `Invalid status transition: ${currentStatus} -> Released`,
+ );
+ }
+
+ const check = getReleaseCheck(invoiceId);
+ if (!check.allowed) {
+ throw new Error(
+ `Release blocked for invoice ${invoiceId}: ${check.reasons.join("; ")}`,
+ );
+ }
+
+ return "Released";
+}
diff --git a/src/lib/ipfs.ts b/src/lib/ipfs.ts
index 6320c4c1..fd584eb7 100644
--- a/src/lib/ipfs.ts
+++ b/src/lib/ipfs.ts
@@ -138,3 +138,69 @@ export async function uploadToIpfsWithFallback(file: File): Promise {
return mockIpfsUpload(file);
}
}
+
+/**
+ * Metadata describing an uploaded evidence file.
+ */
+export interface EvidenceUploadResult {
+ cid: string;
+ filename: string;
+ size: number;
+ uploadedAt: string;
+}
+
+/**
+ * Upload an evidence file to IPFS and return its metadata.
+ * Used for both milestone evidence and dispute evidence submission.
+ * File type and size are validated using the same rules as uploadToIpfs.
+ *
+ * @param file - The evidence file to upload
+ * @returns Metadata for the uploaded evidence
+ * @throws Error if the file is invalid or upload fails
+ */
+export async function uploadEvidence(file: File): Promise {
+ // Validate file size (max 10MB)
+ const MAX_SIZE = 10 * 1024 * 1024;
+ if (file.size > MAX_SIZE) {
+ throw new Error("File size exceeds 10MB limit");
+ }
+
+ // Validate file type
+ const allowedTypes = [
+ "application/pdf",
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "text/plain",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ];
+
+ if (!allowedTypes.includes(file.type)) {
+ throw new Error("File type not supported. Use PDF, images, or text documents.");
+ }
+
+ const cid = await uploadToIpfsWithFallback(file);
+
+ if (!isValidCid(cid)) {
+ throw new Error("Invalid CID returned from IPFS upload");
+ }
+
+ return {
+ cid,
+ filename: file.name,
+ size: file.size,
+ uploadedAt: new Date().toISOString(),
+ };
+}
+
+/**
+ * Validate an evidence reference (CID) used for milestone or dispute evidence.
+ *
+ * @param cid - The IPFS CID to validate
+ * @returns true if the CID is a valid evidence reference, false otherwise
+ */
+export function validateEvidenceReference(cid: string): boolean {
+ if (!cid || typeof cid !== "string") return false;
+ return isValidCid(cid.trim());
+}
diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts
index 2c8798fa..a8fa562e 100644
--- a/src/lib/notifications.ts
+++ b/src/lib/notifications.ts
@@ -45,6 +45,12 @@ interface NotificationPreferences {
deadlineApproaching: boolean;
invoiceReleased: boolean;
invoiceRefunded: boolean;
+ milestoneConditionMet: boolean;
+ milestoneReleased: boolean;
+ disputeOpened: boolean;
+ disputeEvidenceSubmitted: boolean;
+ disputeResolved: boolean;
+ disputeEnforced: boolean;
}
const DEFAULT_PREFS: NotificationPreferences = {
@@ -53,6 +59,12 @@ const DEFAULT_PREFS: NotificationPreferences = {
deadlineApproaching: true,
invoiceReleased: true,
invoiceRefunded: true,
+ milestoneConditionMet: true,
+ milestoneReleased: true,
+ disputeOpened: true,
+ disputeEvidenceSubmitted: true,
+ disputeResolved: true,
+ disputeEnforced: true,
};
function getPreferences(): NotificationPreferences {
diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts
index ea96d054..dd4f6a31 100644
--- a/src/lib/stellar.ts
+++ b/src/lib/stellar.ts
@@ -412,3 +412,201 @@ export function validateRecipientAmountSum(amounts: number[], totalAmount: numbe
const sumStroops = amounts.reduce((s, a) => s + Math.round(a * STROOP_SCALE), 0);
return totalStroops === sumStroops;
}
+
+/**
+ * Release funds for a milestone, but only once every condition attached to
+ * that milestone has been satisfied and approved.
+ *
+ * The milestone release gate (`canReleaseMilestone`) is evaluated first; if
+ * any condition is unmet this throws before any transaction is built, so no
+ * funds can move. On success the existing contract `release` path is invoked
+ * and a notification is emitted.
+ */
+export async function releaseMilestoneFunds(params: {
+ invoiceId: string;
+ milestoneId: string;
+ payer: string;
+}): Promise<{ txHash: string }> {
+ const { canReleaseMilestone } = await import("./milestones");
+ const { notifyMilestoneReleased } = await import("./notifications");
+
+ const gate = await canReleaseMilestone({
+ invoiceId: params.invoiceId,
+ milestoneId: params.milestoneId,
+ });
+
+ if (!gate.canRelease) {
+ const unmet = gate.unmetConditions?.length
+ ? ` Unmet conditions: ${gate.unmetConditions.join(", ")}.`
+ : "";
+ throw new Error(`Milestone conditions are not satisfied.${unmet}`);
+ }
+
+ const client = getSplitClient();
+ const { TransactionBuilder, BASE_FEE, Memo, nativeToScVal, rpc } = await import("@stellar/stellar-sdk");
+ const { signWithFreighter } = await import("./freighter");
+
+ // Same rationale as `payMilestone`: the published SDK does not expose a
+ // typed milestone-release call with a custom memo, so reach the underlying
+ // internals at runtime.
+ const internals = client as unknown as {
+ contract: { call: (...args: any[]) => any };
+ server: any;
+ config: { networkPassphrase: string };
+ };
+
+ const memoText = `INV:${params.invoiceId}:REL:${params.milestoneId}`.slice(0, MEMO_MAX_BYTES);
+
+ const operation = internals.contract.call(
+ "release_milestone",
+ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }),
+ nativeToScVal(params.milestoneId, { type: "string" })
+ );
+
+ const sourceAccount = await internals.server.getAccount(params.payer);
+ const networkPassphrase = internals.config.networkPassphrase;
+
+ const tx = new TransactionBuilder(sourceAccount, {
+ fee: BASE_FEE,
+ networkPassphrase,
+ })
+ .addOperation(operation)
+ .addMemo(Memo.text(memoText))
+ .setTimeout(30)
+ .build();
+
+ const simResult = await internals.server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(simResult)) {
+ throw new Error(`Simulation failed: ${simResult.error}`);
+ }
+
+ const preparedTx = rpc.assembleTransaction(tx, simResult).build();
+ const signedXdr = await signWithFreighter(preparedTx.toXDR());
+ const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase);
+ const sendResult = await internals.server.sendTransaction(signedTx);
+
+ if (sendResult.status === "ERROR") {
+ throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`);
+ }
+
+ let getResult = await internals.server.getTransaction(sendResult.hash);
+ let attempts = 0;
+ while (getResult.status === rpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20) {
+ await new Promise((r) => setTimeout(r, 1500));
+ getResult = await internals.server.getTransaction(sendResult.hash);
+ attempts++;
+ }
+
+ if (getResult.status !== rpc.Api.GetTransactionStatus.SUCCESS) {
+ throw new Error(`Transaction not confirmed: ${getResult.status}`);
+ }
+
+ await notifyMilestoneReleased({
+ invoiceId: params.invoiceId,
+ milestoneId: params.milestoneId,
+ txHash: sendResult.hash,
+ });
+
+ return { txHash: sendResult.hash };
+}
+
+export type DisputeOutcome = "release" | "refund" | "split";
+
+/**
+ * Enforce a resolved dispute by invoking the matching contract method.
+ *
+ * - `release` pays the recipient the disputed funds.
+ * - `refund` returns the disputed funds to the payer.
+ * - `split` divides the disputed funds according to the resolution's split
+ * ratio (defaults to an even split when none is supplied).
+ *
+ * A notification is emitted on success so both parties are informed.
+ */
+export async function enforceDisputeResolution(params: {
+ invoiceId: string;
+ outcome: DisputeOutcome;
+ payer: string;
+ splitRatio?: number;
+}): Promise<{ txHash: string }> {
+ const { notifyDisputeResolved } = await import("./notifications");
+
+ const methodByOutcome: Record = {
+ release: "release_disputed_funds",
+ refund: "refund_disputed_funds",
+ split: "split_disputed_funds",
+ };
+
+ const method = methodByOutcome[params.outcome];
+ if (!method) {
+ throw new Error(`Unsupported dispute outcome: ${params.outcome}`);
+ }
+
+ const client = getSplitClient();
+ const { TransactionBuilder, BASE_FEE, Memo, nativeToScVal, rpc } = await import("@stellar/stellar-sdk");
+ const { signWithFreighter } = await import("./freighter");
+
+ const internals = client as unknown as {
+ contract: { call: (...args: any[]) => any };
+ server: any;
+ config: { networkPassphrase: string };
+ };
+
+ const memoText = `INV:${params.invoiceId}:DSP:${params.outcome}`.slice(0, MEMO_MAX_BYTES);
+
+ const callArgs: any[] = [nativeToScVal(BigInt(params.invoiceId), { type: "u64" })];
+ if (params.outcome === "split") {
+ const ratio = params.splitRatio ?? 0.5;
+ if (ratio < 0 || ratio > 1) {
+ throw new Error("splitRatio must be between 0 and 1.");
+ }
+ callArgs.push(nativeToScVal(Math.round(ratio * 10_000), { type: "u32" }));
+ }
+
+ const operation = internals.contract.call(method, ...callArgs);
+
+ const sourceAccount = await internals.server.getAccount(params.payer);
+ const networkPassphrase = internals.config.networkPassphrase;
+
+ const tx = new TransactionBuilder(sourceAccount, {
+ fee: BASE_FEE,
+ networkPassphrase,
+ })
+ .addOperation(operation)
+ .addMemo(Memo.text(memoText))
+ .setTimeout(30)
+ .build();
+
+ const simResult = await internals.server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(simResult)) {
+ throw new Error(`Simulation failed: ${simResult.error}`);
+ }
+
+ const preparedTx = rpc.assembleTransaction(tx, simResult).build();
+ const signedXdr = await signWithFreighter(preparedTx.toXDR());
+ const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase);
+ const sendResult = await internals.server.sendTransaction(signedTx);
+
+ if (sendResult.status === "ERROR") {
+ throw new Error(`Transaction failed: ${JSON.stringify(sendResult.errorResult)}`);
+ }
+
+ let getResult = await internals.server.getTransaction(sendResult.hash);
+ let attempts = 0;
+ while (getResult.status === rpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20) {
+ await new Promise((r) => setTimeout(r, 1500));
+ getResult = await internals.server.getTransaction(sendResult.hash);
+ attempts++;
+ }
+
+ if (getResult.status !== rpc.Api.GetTransactionStatus.SUCCESS) {
+ throw new Error(`Transaction not confirmed: ${getResult.status}`);
+ }
+
+ await notifyDisputeResolved({
+ invoiceId: params.invoiceId,
+ outcome: params.outcome,
+ txHash: sendResult.hash,
+ });
+
+ return { txHash: sendResult.hash };
+}
diff --git a/src/types/activity.ts b/src/types/activity.ts
index 6d50da5a..02b6fd92 100644
--- a/src/types/activity.ts
+++ b/src/types/activity.ts
@@ -2,7 +2,13 @@ export type ActivityEventType =
| "payment_received"
| "status_change"
| "comment"
- | "co_creator_action";
+ | "co_creator_action"
+ | "milestone_condition_met"
+ | "milestone_released"
+ | "dispute_opened"
+ | "dispute_evidence_submitted"
+ | "dispute_resolved"
+ | "dispute_enforced";
export interface ActivityEvent {
eventId: string;
@@ -23,4 +29,4 @@ export interface ActivityEventSnapshot {
events: ActivityEvent[];
}
-export type ActivityFeedMessage = ActivityEventMessage | ActivityEventSnapshot;
+export type ActivityFeedMessage = ActivityEventMessage | ActivityEventSnapshot;
\ No newline at end of file