Skip to content

Commit d4ad1f3

Browse files
committed
feat: add auto-release feature for escrows at expiry
1 parent 2b0d6ac commit d4ad1f3

File tree

16 files changed

+390
-25
lines changed

16 files changed

+390
-25
lines changed

src/app/api/cron/monitor-payments/escrow-monitor.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,42 @@ async function autoRefundExpiredFundedEscrows(
121121
): Promise<void> {
122122
const { data: expiredFundedEscrows } = await supabase
123123
.from('escrows')
124-
.select('id, escrow_address, chain, amount, deposited_amount, depositor_address, expires_at')
124+
.select('id, escrow_address, chain, amount, deposited_amount, depositor_address, expires_at, beneficiary_address, allow_auto_release')
125125
.eq('status', 'funded')
126126
.lt('expires_at', now.toISOString())
127127
.limit(20);
128128

129129
if (!expiredFundedEscrows || expiredFundedEscrows.length === 0) return;
130130

131-
console.log(`Auto-refunding ${expiredFundedEscrows.length} expired funded escrows`);
131+
console.log(`Processing ${expiredFundedEscrows.length} expired funded escrows (auto-release/auto-refund)`);
132132

133133
for (const escrow of expiredFundedEscrows) {
134134
try {
135+
if (escrow.allow_auto_release) {
136+
await supabase
137+
.from('escrows')
138+
.update({
139+
status: 'released',
140+
released_at: now.toISOString(),
141+
})
142+
.eq('id', escrow.id)
143+
.eq('status', 'funded');
144+
145+
await supabase.from('escrow_events').insert({
146+
escrow_id: escrow.id,
147+
event_type: 'released',
148+
actor: 'system',
149+
details: {
150+
reason: 'Escrow expired — auto-release enabled',
151+
release_to: escrow.beneficiary_address,
152+
amount: escrow.deposited_amount || escrow.amount,
153+
},
154+
});
155+
156+
console.log(`Escrow ${escrow.id} expired while funded — auto-released`);
157+
continue;
158+
}
159+
135160
await supabase
136161
.from('escrows')
137162
.update({

src/app/api/cron/monitor-payments/series-monitor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export async function monitorSeries(
8787
period: nextPeriod,
8888
description: series.description || undefined,
8989
},
90+
allow_auto_release: Boolean(series.allow_auto_release),
9091
}, isPaidTier);
9192

9293
if (escrowResult.success) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* POST /api/escrow/:id/auto-release — Toggle auto-release on expiry
3+
* Auth: release_token (depositor only)
4+
*/
5+
6+
import { NextRequest, NextResponse } from 'next/server';
7+
import { createClient } from '@supabase/supabase-js';
8+
import { setEscrowAutoRelease } from '@/lib/escrow';
9+
10+
function getSupabase() {
11+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
12+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
13+
if (!url || !key) throw new Error('Supabase not configured');
14+
return createClient(url, key);
15+
}
16+
17+
export async function POST(
18+
request: NextRequest,
19+
{ params }: { params: Promise<{ id: string }> }
20+
) {
21+
try {
22+
const { id } = await params;
23+
const body = await request.json();
24+
25+
if (!body.release_token) {
26+
return NextResponse.json(
27+
{ error: 'release_token is required' },
28+
{ status: 400 }
29+
);
30+
}
31+
32+
if (typeof body.allow_auto_release !== 'boolean') {
33+
return NextResponse.json(
34+
{ error: 'allow_auto_release must be a boolean' },
35+
{ status: 400 }
36+
);
37+
}
38+
39+
const supabase = getSupabase();
40+
const result = await setEscrowAutoRelease(supabase, id, body.release_token, body.allow_auto_release);
41+
42+
if (!result.success) {
43+
const status = result.error?.includes('Unauthorized') ? 403 :
44+
result.error?.includes('not found') ? 404 : 400;
45+
return NextResponse.json({ error: result.error }, { status });
46+
}
47+
48+
return NextResponse.json(result.escrow);
49+
} catch (error) {
50+
console.error('Failed to update escrow auto-release:', error);
51+
return NextResponse.json(
52+
{ error: 'Internal server error' },
53+
{ status: 500 }
54+
);
55+
}
56+
}

src/app/api/escrow/route.ts

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export async function GET(request: NextRequest) {
8888
const apiKeyHeader = request.headers.get('x-api-key');
8989
let merchantId: string | undefined;
9090
let businessIds: string[] | undefined;
91+
let scopedWalletAddresses: string[] = [];
9192

9293
if (authHeader || apiKeyHeader) {
9394
try {
@@ -115,31 +116,118 @@ export async function GET(request: NextRequest) {
115116

116117
if (businesses && businesses.length > 0) {
117118
businessIds = businesses.map((b: { id: string }) => b.id);
118-
} else {
119-
// Merchant has no businesses — return empty
120-
return NextResponse.json({ escrows: [], total: 0, limit: filters.limit, offset: filters.offset });
121119
}
120+
121+
// Also scope by wallets owned by this merchant (global + business wallets)
122+
const walletAddressSet = new Set<string>();
123+
124+
const { data: merchantWallets } = await supabase
125+
.from('merchant_wallets')
126+
.select('wallet_address')
127+
.eq('merchant_id', merchantId)
128+
.eq('is_active', true);
129+
130+
for (const row of merchantWallets || []) {
131+
if (row.wallet_address) walletAddressSet.add(row.wallet_address);
132+
}
133+
134+
if (businessIds && businessIds.length > 0) {
135+
const { data: businessWallets } = await supabase
136+
.from('business_wallets')
137+
.select('wallet_address')
138+
.in('business_id', businessIds)
139+
.eq('is_active', true);
140+
141+
for (const row of businessWallets || []) {
142+
if (row.wallet_address) walletAddressSet.add(row.wallet_address);
143+
}
144+
}
145+
146+
scopedWalletAddresses = Array.from(walletAddressSet);
122147
}
123148

124-
// Must have at least one scoping filter (don't allow listing all escrows)
125-
const hasFilter = filters.status || filters.depositor_address ||
126-
filters.beneficiary_address || filters.business_id || businessIds;
127-
if (!hasFilter) {
149+
// Must have a scoping filter (status alone must NOT allow listing all escrows)
150+
const hasScope = Boolean(
151+
filters.depositor_address ||
152+
filters.beneficiary_address ||
153+
filters.business_id ||
154+
(businessIds && businessIds.length > 0) ||
155+
(scopedWalletAddresses && scopedWalletAddresses.length > 0)
156+
);
157+
if (!hasScope) {
128158
return NextResponse.json(
129-
{ error: 'At least one filter required (status, depositor, beneficiary, or business_id)' },
159+
{ error: 'A scoping filter is required (depositor, beneficiary, business_id, or authenticated account scope)' },
130160
{ status: 400 }
131161
);
132162
}
133163

134-
const result = await listEscrows(supabase, { ...filters, business_ids: businessIds } as any);
164+
const offset = Number(filters.offset || 0);
165+
const limit = Number(filters.limit || 20);
135166

136-
if (!result.success) {
137-
return NextResponse.json({ error: result.error }, { status: 500 });
167+
// If the caller explicitly scopes by depositor/beneficiary/business_id, keep direct behavior.
168+
const hasExplicitPartyScope = Boolean(filters.depositor_address || filters.beneficiary_address || filters.business_id);
169+
if (hasExplicitPartyScope) {
170+
const result = await listEscrows(supabase, { ...filters, business_ids: businessIds } as any);
171+
172+
if (!result.success) {
173+
return NextResponse.json({ error: result.error }, { status: 500 });
174+
}
175+
176+
return NextResponse.json({
177+
escrows: result.escrows,
178+
total: result.total,
179+
limit: filters.limit,
180+
offset: filters.offset,
181+
});
182+
}
183+
184+
// Implicit authenticated account scope: union of business escrows + wallet-party escrows.
185+
const aggregate = new Map<string, any>();
186+
const queries: Array<Promise<{ success: boolean; escrows?: any[]; total?: number; error?: string }>> = [];
187+
188+
if (businessIds && businessIds.length > 0) {
189+
queries.push(listEscrows(supabase, {
190+
...filters,
191+
business_ids: businessIds,
192+
limit: 500,
193+
offset: 0,
194+
} as any));
195+
}
196+
197+
if (scopedWalletAddresses.length > 0) {
198+
queries.push(listEscrows(supabase, {
199+
...filters,
200+
depositor_addresses: scopedWalletAddresses,
201+
limit: 500,
202+
offset: 0,
203+
} as any));
204+
queries.push(listEscrows(supabase, {
205+
...filters,
206+
beneficiary_addresses: scopedWalletAddresses,
207+
limit: 500,
208+
offset: 0,
209+
} as any));
210+
}
211+
212+
const results = await Promise.all(queries);
213+
for (const result of results) {
214+
if (!result.success) {
215+
return NextResponse.json({ error: result.error }, { status: 500 });
216+
}
217+
for (const escrow of result.escrows || []) {
218+
aggregate.set(escrow.id, escrow);
219+
}
138220
}
139221

222+
const mergedEscrows = Array.from(aggregate.values()).sort((a, b) =>
223+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
224+
);
225+
226+
const pagedEscrows = mergedEscrows.slice(offset, offset + limit);
227+
140228
return NextResponse.json({
141-
escrows: result.escrows,
142-
total: result.total,
229+
escrows: pagedEscrows,
230+
total: mergedEscrows.length,
143231
limit: filters.limit,
144232
offset: filters.offset,
145233
});

src/app/api/escrow/series/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export async function POST(request: NextRequest) {
3333
beneficiary_address,
3434
depositor_address,
3535
beneficiary_email,
36+
allow_auto_release = false,
3637
} = body;
3738

3839
// Validate required fields before touching DB
@@ -87,6 +88,7 @@ export async function POST(request: NextRequest) {
8788
max_periods: max_periods || null,
8889
beneficiary_address,
8990
depositor_address,
91+
allow_auto_release: Boolean(allow_auto_release),
9092
})
9193
.select()
9294
.single();
@@ -115,6 +117,7 @@ export async function POST(request: NextRequest) {
115117
period: 1,
116118
description: description || undefined,
117119
},
120+
allow_auto_release: Boolean(allow_auto_release),
118121
...(customer_email ? { depositor_email: customer_email } : {}),
119122
...(beneficiary_email ? { beneficiary_email } : {}),
120123
};

src/app/escrow/create/page.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ interface CreatedEscrow {
5050
status: string;
5151
release_token: string;
5252
beneficiary_token: string;
53+
allow_auto_release?: boolean;
5354
expires_at: string;
5455
created_at: string;
5556
metadata: Record<string, unknown>;
@@ -76,6 +77,7 @@ export default function CreateEscrowPage() {
7677
description: '',
7778
expires_in_hours: 168,
7879
business_id: '',
80+
allow_auto_release: false,
7981
});
8082

8183
// Recurring escrow state
@@ -272,6 +274,7 @@ export default function CreateEscrowPage() {
272274
depositor_address: formData.depositor_address.trim(),
273275
beneficiary_address: formData.beneficiary_address.trim(),
274276
expires_in_hours: formData.expires_in_hours,
277+
allow_auto_release: formData.allow_auto_release,
275278
};
276279

277280
if (formData.arbiter_address.trim()) {
@@ -487,6 +490,7 @@ export default function CreateEscrowPage() {
487490
`Depositor: ${createdEscrow.depositor_address}`,
488491
`Beneficiary: ${createdEscrow.beneficiary_address}`,
489492
`Expires: ${new Date(createdEscrow.expires_at).toLocaleString()}`,
493+
`Auto-release at expiry: ${createdEscrow.allow_auto_release ? 'Enabled' : 'Disabled'}`,
490494
`Release Token: ${createdEscrow.release_token}`,
491495
`Beneficiary Token: ${createdEscrow.beneficiary_token}`,
492496
...(createdEscrow.fee_amount ? [`Commission: ${createdEscrow.fee_amount} ${createdEscrow.chain}`] : []),
@@ -661,6 +665,12 @@ export default function CreateEscrowPage() {
661665
{new Date(createdEscrow.expires_at).toLocaleString()}
662666
</span>
663667
</div>
668+
<div>
669+
<span className="text-gray-500 dark:text-gray-400">Auto-release at expiry:</span>
670+
<span className="ml-2 text-gray-900 dark:text-white">
671+
{createdEscrow.allow_auto_release ? 'Enabled' : 'Disabled'}
672+
</span>
673+
</div>
664674
{createdEscrow.fee_amount != null && createdEscrow.fee_amount > 0 && (
665675
<div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
666676
<span className="text-sm font-medium text-amber-800 dark:text-amber-300">Platform Commission:</span>
@@ -1029,6 +1039,21 @@ export default function CreateEscrowPage() {
10291039
</select>
10301040
</div>
10311041

1042+
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
1043+
<label className="flex items-start gap-3 cursor-pointer">
1044+
<input
1045+
type="checkbox"
1046+
checked={formData.allow_auto_release}
1047+
onChange={(e) => setFormData({ ...formData, allow_auto_release: e.target.checked })}
1048+
className="mt-0.5 w-4 h-4 text-blue-600 bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 rounded focus:ring-blue-500"
1049+
/>
1050+
<span className="text-sm text-gray-700 dark:text-gray-300">
1051+
<span className="font-medium">Allow auto-release</span>
1052+
{' '}to release funds to the beneficiary when the escrow expiry is reached (instead of auto-refund).
1053+
</span>
1054+
</label>
1055+
</div>
1056+
10321057
{/* Description */}
10331058
<div>
10341059
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">

0 commit comments

Comments
 (0)