Skip to content

Commit 2889521

Browse files
Harden trust paths and simplify first-task funnel (#78)
Merge PR #78 after local verification, green CI, and final council review.
1 parent df63cfe commit 2889521

4 files changed

Lines changed: 476 additions & 137 deletions

File tree

backend/api_core.py

Lines changed: 125 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import base64
99
import gzip
1010
import json
11+
import math
1112
import os
1213
import sys
1314
import sqlite3
@@ -1103,7 +1104,7 @@ def send_welcome_email(email, name):
11031104
<div style="text-align:center;margin-bottom:24px">
11041105
<a href="https://www.gohirehumans.com" style="display:inline-block;background:#0d7377;color:white;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px">Get Started →</a>
11051106
</div>
1106-
<p style="font-size:13px;color:#6b6963;margin-bottom:8px">Every transaction is protected by escrow. Funds only release when you approve the work.</p>
1107+
<p style="font-size:13px;color:#6b6963;margin-bottom:8px">Where payment processing is configured, paid work uses platform payment records and review steps before funds are released or refunded.</p>
11071108
<p style="font-size:13px;color:#6b6963">Questions? Reply to this email or check our <a href="https://www.gohirehumans.com/faq.html" style="color:#0d7377">FAQ</a>.</p>
11081109
<hr style="border:none;border-top:1px solid #dddbd6;margin:24px 0 16px">
11091110
<p style="font-size:11px;color:#a8a6a0;text-align:center">&copy; 2026 GoHireHumans · <a href="https://www.gohirehumans.com" style="color:#a8a6a0">gohirehumans.com</a></p>
@@ -1154,7 +1155,12 @@ def worker_has_payout_setup(db, user_id):
11541155
wp = db.execute("SELECT payout_account_id, payout_method FROM worker_profiles WHERE user_id = ?", [user_id]).fetchone()
11551156
if not wp:
11561157
return False
1157-
return bool(wp['payout_account_id']) and wp['payout_method'] not in ('pending_setup', None, '')
1158+
payout_account_id = wp['payout_account_id'] or ''
1159+
if PRODUCTION_MODE and payout_account_id.startswith('acct_sim_'):
1160+
return False
1161+
if (stripe_configured() or PRODUCTION_MODE) and not payout_account_id:
1162+
return False
1163+
return bool(payout_account_id) and wp['payout_method'] not in ('pending_setup', None, '')
11581164

11591165

11601166
def employer_has_payment_setup(db, user_id):
@@ -1170,40 +1176,39 @@ def employer_has_payment_setup(db, user_id):
11701176

11711177
def release_escrow_to_worker(db, order_id, milestone_id, amount, worker_id):
11721178
"""Release escrow hold, transfer to worker via Stripe or simulation."""
1173-
# Mark escrow released
1174-
db.execute(
1175-
"UPDATE escrow_holds SET status='released', released_at=datetime('now') WHERE order_id=? AND (milestone_id=? OR milestone_id IS NULL) AND status='held'",
1176-
[order_id, milestone_id]
1177-
)
11781179
# Employer pays the 1% platform margin on top of the listed amount.
11791180
# Workers receive the listed amount unless Enzo explicitly changes the model.
11801181
fee = round(amount * SERVICE_FEE_RATE, 2)
11811182
worker_payout = round(amount, 2)
11821183

1184+
if stripe_configured() or PRODUCTION_MODE:
1185+
wp = db.execute("SELECT payout_account_id FROM worker_profiles WHERE user_id = ?", [worker_id]).fetchone()
1186+
payout_account_id = (wp['payout_account_id'] if wp else '') or ''
1187+
if not payout_account_id or payout_account_id.startswith('acct_sim_'):
1188+
raise ValueError("A live worker Stripe Connect payout account is required before release.")
1189+
if not stripe_configured():
1190+
raise ValueError("Stripe is not configured; live payout release is disabled in production.")
1191+
try:
1192+
stripe.Transfer.create(
1193+
amount=int(worker_payout * 100),
1194+
currency="usd",
1195+
destination=payout_account_id,
1196+
metadata={"order_id": str(order_id), "milestone_id": str(milestone_id or "")},
1197+
description=f"GoHireHumans escrow release order #{order_id}",
1198+
idempotency_key=f"escrow-release:{order_id}:{milestone_id or 'full'}:{int(worker_payout * 100)}"
1199+
)
1200+
except stripe.error.StripeError as e:
1201+
raise ValueError(f"Stripe transfer failed: {str(e)}")
1202+
1203+
# Only mark escrow/revenue after live transfer succeeds or non-production simulation is allowed.
1204+
db.execute(
1205+
"UPDATE escrow_holds SET status='released', released_at=datetime('now') WHERE order_id=? AND (milestone_id=? OR milestone_id IS NULL) AND status='held'",
1206+
[order_id, milestone_id]
1207+
)
11831208
db.execute(
11841209
"INSERT INTO platform_revenue (order_id, fee_amount, fee_type) VALUES (?,?,?)",
11851210
[order_id, fee, 'service_fee']
11861211
)
1187-
1188-
# Attempt Stripe transfer if configured
1189-
if stripe_configured():
1190-
wp = db.execute("SELECT payout_account_id FROM worker_profiles WHERE user_id = ?", [worker_id]).fetchone()
1191-
if wp and wp['payout_account_id'] and not wp['payout_account_id'].startswith('acct_sim_'):
1192-
try:
1193-
stripe.Transfer.create(
1194-
amount=int(worker_payout * 100),
1195-
currency="usd",
1196-
destination=wp['payout_account_id'],
1197-
metadata={"order_id": str(order_id), "milestone_id": str(milestone_id or "")},
1198-
description=f"GoHireHumans escrow release order #{order_id}"
1199-
)
1200-
except stripe.error.StripeError as e:
1201-
db.execute(
1202-
"UPDATE escrow_holds SET status='held' WHERE order_id=? AND (milestone_id=? OR milestone_id IS NULL)",
1203-
[order_id, milestone_id]
1204-
)
1205-
raise ValueError(f"Stripe transfer failed: {str(e)}")
1206-
12071212
return worker_payout, fee
12081213

12091214

@@ -2744,7 +2749,11 @@ def _handle_routes(db):
27442749
ms_amount = float(current_ms['amount'])
27452750

27462751
# Release escrow for this milestone
2747-
worker_payout, fee = release_escrow_to_worker(db, order_id, ms_id, ms_amount, order['worker_id'])
2752+
try:
2753+
worker_payout, fee = release_escrow_to_worker(db, order_id, ms_id, ms_amount, order['worker_id'])
2754+
except ValueError as e:
2755+
db.rollback()
2756+
return error_response(str(e), 502)
27482757

27492758
db.execute(
27502759
"UPDATE milestones SET status='approved', released_at=datetime('now') WHERE id=?",
@@ -3056,15 +3065,34 @@ def _handle_routes(db):
30563065
total_hours = sum(float(e['hours']) for e in entries)
30573066
total_pay = round(total_hours * float(hc['hourly_rate']), 2)
30583067
fee = round(total_pay * SERVICE_FEE_RATE, 2)
3059-
worker_pay = round(total_pay - fee, 2)
3068+
worker_pay = round(total_pay, 2)
3069+
3070+
if stripe_configured() or PRODUCTION_MODE:
3071+
wp = db.execute("SELECT payout_account_id FROM worker_profiles WHERE user_id=?", [order['worker_id']]).fetchone()
3072+
payout_account_id = (wp['payout_account_id'] if wp else '') or ''
3073+
if not payout_account_id or payout_account_id.startswith('acct_sim_'):
3074+
return error_response("A live worker Stripe Connect payout account is required before approving paid hours.", 409)
3075+
if not stripe_configured():
3076+
return error_response("Stripe is not configured; live hourly payout release is disabled in production.", 503)
3077+
try:
3078+
stripe.Transfer.create(
3079+
amount=int(worker_pay * 100),
3080+
currency="usd",
3081+
destination=payout_account_id,
3082+
metadata={"order_id": str(order_id), "week_of": week_of},
3083+
idempotency_key=f"hourly-release:{order_id}:{week_of}:{int(worker_pay * 100)}"
3084+
)
3085+
except stripe.error.StripeError as e:
3086+
db.rollback()
3087+
return error_response(f"Stripe transfer failed: {str(e)}", 502)
30603088

3061-
# Mark entries approved
3089+
# Mark entries approved only after live transfer succeeds or non-production simulation is allowed.
30623090
db.execute(
30633091
"UPDATE time_entries SET status='approved' WHERE contract_id=? AND week_of=? AND status='pending'",
30643092
[hc['id'], week_of]
30653093
)
30663094

3067-
# Release escrow for these hours
3095+
# Release escrow for these hours after transfer/fail-closed checks above.
30683096
db.execute(
30693097
"UPDATE escrow_holds SET status='released', released_at=datetime('now') WHERE order_id=? AND status='held'",
30703098
[order_id]
@@ -3074,20 +3102,6 @@ def _handle_routes(db):
30743102
[order_id, fee]
30753103
)
30763104

3077-
# Transfer to worker if Stripe configured
3078-
if stripe_configured():
3079-
wp = db.execute("SELECT payout_account_id FROM worker_profiles WHERE user_id=?", [order['worker_id']]).fetchone()
3080-
if wp and wp['payout_account_id'] and not wp['payout_account_id'].startswith('acct_sim_'):
3081-
try:
3082-
stripe.Transfer.create(
3083-
amount=int(worker_pay * 100),
3084-
currency="usd",
3085-
destination=wp['payout_account_id'],
3086-
metadata={"order_id": str(order_id), "week_of": week_of}
3087-
)
3088-
except stripe.error.StripeError:
3089-
pass
3090-
30913105
# Refund unused escrow and fund next week
30923106
escrow_held = float(hc['current_week_escrow_amount'] or 0)
30933107
unused = max(0, round(escrow_held - total_pay, 2))
@@ -3416,6 +3430,8 @@ def _handle_routes(db):
34163430
except stripe.error.StripeError as e:
34173431
return error_response(f"Stripe error: {str(e)}", 502)
34183432
else:
3433+
if PRODUCTION_MODE:
3434+
return error_response("Stripe is not configured; simulated worker payout setup is disabled in production.", 503)
34193435
# Simulation
34203436
body = get_body()
34213437
payout_account_id = f"acct_sim_{secrets.token_hex(10)}"
@@ -3456,7 +3472,10 @@ def _handle_routes(db):
34563472
except stripe.error.StripeError:
34573473
worker_status = {"connected": False, "account_id": wp['payout_account_id'], "mode": "live"}
34583474
else:
3459-
worker_status = {"connected": True, "account_id": wp['payout_account_id'], "mode": "simulated"}
3475+
if PRODUCTION_MODE:
3476+
worker_status = {"connected": False, "account_id": None, "mode": "disabled", "message": "Simulated worker payout is disabled in production."}
3477+
else:
3478+
worker_status = {"connected": True, "account_id": wp['payout_account_id'], "mode": "simulated"}
34603479
else:
34613480
worker_status = {"connected": False, "account_id": None}
34623481

@@ -4107,85 +4126,79 @@ def _handle_routes(db):
41074126
return error_response("Order must be disputed to resolve", 409)
41084127

41094128
admin_notes = body.get("notes", "")
4129+
step_error, step_status = require_admin_step_up(db, user, body, "admin_resolve_dispute")
4130+
if step_error:
4131+
return error_response(step_error, step_status)
41104132

4111-
if resolution == 'release_to_worker':
4112-
# Release all held escrow to worker
4113-
holds = db.execute(
4114-
"SELECT * FROM escrow_holds WHERE order_id=? AND status='held'",
4115-
[int(order_id)]
4116-
).fetchall()
4117-
total_released = 0
4118-
for hold in holds:
4119-
db.execute(
4120-
"UPDATE escrow_holds SET status='released', released_at=datetime('now') WHERE id=?",
4121-
[hold['id']]
4122-
)
4123-
total_released += float(hold['amount'])
4124-
4125-
fee = round(total_released * SERVICE_FEE_RATE, 2)
4126-
worker_pay = round(total_released - fee, 2)
4127-
db.execute(
4128-
"INSERT INTO platform_revenue (order_id, fee_amount, fee_type) VALUES (?,?,'dispute_resolution')",
4129-
[order_id, fee]
4133+
if body.get("manual_money_movement_confirmed") is not True:
4134+
return error_response(
4135+
"Manual money movement confirmation required. Complete and verify any Stripe refund/transfer outside this admin action before recording the dispute resolution.",
4136+
409
41304137
)
4138+
processor_reference = str(body.get("processor_reference") or "").strip()
4139+
if not processor_reference:
4140+
return error_response("processor_reference required for manual dispute settlement audit trail", 400)
41314141

4132-
push_notification(db, order['worker_id'], "dispute_resolved",
4133-
"Dispute resolved in your favor",
4134-
f"${worker_pay:.2f} has been released to you.",
4135-
f"/orders/{order_id}")
4136-
push_notification(db, order['employer_id'], "dispute_resolved",
4137-
"Dispute resolved",
4138-
f"The dispute for order #{order_id} was resolved in the worker's favor.",
4139-
f"/orders/{order_id}")
4142+
holds = db.execute(
4143+
"SELECT * FROM escrow_holds WHERE order_id=? AND status='held'",
4144+
[int(order_id)]
4145+
).fetchall()
4146+
if not holds:
4147+
return error_response("No held payment record available to resolve", 409)
41404148

4141-
elif resolution == 'refund_to_employer':
4149+
total_held = round(sum(float(hold['amount']) for hold in holds), 2)
4150+
worker_percent = 100.0 if resolution == 'release_to_worker' else 0.0
4151+
if resolution == 'split':
4152+
try:
4153+
worker_percent = float(body.get("worker_percent", 50))
4154+
except (TypeError, ValueError):
4155+
return error_response("worker_percent must be a number between 0 and 100", 400)
4156+
if not math.isfinite(worker_percent) or worker_percent < 0 or worker_percent > 100:
4157+
return error_response("worker_percent must be a finite number between 0 and 100", 400)
4158+
worker_portion = round(total_held * (worker_percent / 100.0), 2)
4159+
employer_portion = round(total_held - worker_portion, 2)
4160+
4161+
escrow_status = 'released' if resolution == 'release_to_worker' else 'refunded' if resolution == 'refund_to_employer' else 'partial'
4162+
db.execute(
4163+
"UPDATE escrow_holds SET status=?, released_at=datetime('now') WHERE order_id=? AND status='held'",
4164+
[escrow_status, int(order_id)]
4165+
)
4166+
if worker_portion > 0:
41424167
db.execute(
4143-
"UPDATE escrow_holds SET status='refunded', released_at=datetime('now') WHERE order_id=? AND status='held'",
4144-
[int(order_id)]
4168+
"INSERT INTO platform_revenue (order_id, fee_amount, fee_type) VALUES (?,?,?)",
4169+
[order_id, round(worker_portion * SERVICE_FEE_RATE, 2), 'manual_dispute_resolution']
41454170
)
4146-
push_notification(db, order['employer_id'], "dispute_resolved",
4147-
"Dispute resolved — refund issued",
4148-
f"Your payment for order #{order_id} has been refunded.",
4149-
f"/orders/{order_id}")
4150-
push_notification(db, order['worker_id'], "dispute_resolved",
4151-
"Dispute resolved",
4152-
f"The dispute for order #{order_id} was resolved in the employer's favor.",
4153-
f"/orders/{order_id}")
41544171

4155-
elif resolution == 'split':
4156-
split_pct = float(body.get("worker_percent", 50)) / 100
4157-
holds = db.execute(
4158-
"SELECT * FROM escrow_holds WHERE order_id=? AND status='held'",
4159-
[int(order_id)]
4160-
).fetchall()
4161-
for hold in holds:
4162-
amount = float(hold['amount'])
4163-
worker_portion = round(amount * split_pct, 2)
4164-
employer_portion = round(amount - worker_portion, 2)
4165-
db.execute(
4166-
"UPDATE escrow_holds SET status='partial', released_at=datetime('now') WHERE id=?",
4167-
[hold['id']]
4168-
)
4169-
db.execute(
4170-
"INSERT INTO platform_revenue (order_id, fee_amount, fee_type) VALUES (?,?,'dispute_split')",
4171-
[order_id, round(worker_portion * SERVICE_FEE_RATE, 2)]
4172-
)
4173-
push_notification(db, order['worker_id'], "dispute_resolved",
4174-
"Dispute resolved — split decision",
4175-
f"The dispute for order #{order_id} was resolved with a split decision.",
4176-
f"/orders/{order_id}")
4177-
push_notification(db, order['employer_id'], "dispute_resolved",
4178-
"Dispute resolved — split decision",
4179-
f"The dispute for order #{order_id} was resolved with a split decision.",
4180-
f"/orders/{order_id}")
4172+
push_notification(db, order['worker_id'], "dispute_resolved",
4173+
"Dispute resolution recorded",
4174+
f"The dispute for order #{order_id} was resolved after manual settlement was verified by an admin.",
4175+
f"/orders/{order_id}")
4176+
push_notification(db, order['employer_id'], "dispute_resolved",
4177+
"Dispute resolution recorded",
4178+
f"The dispute for order #{order_id} was resolved after manual settlement was verified by an admin.",
4179+
f"/orders/{order_id}")
41814180

41824181
db.execute(
41834182
"UPDATE orders SET status='completed', completed_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
41844183
[int(order_id)]
41854184
)
4186-
audit(db, user['id'], "resolve_dispute", "order", int(order_id), {"resolution": resolution, "notes": admin_notes})
4185+
audit(db, user['id'], "resolve_dispute_manual_settlement", "order", int(order_id), {
4186+
"resolution": resolution,
4187+
"notes": admin_notes,
4188+
"manual_money_movement_confirmed": True,
4189+
"processor_reference": processor_reference,
4190+
"worker_portion": worker_portion,
4191+
"employer_portion": employer_portion,
4192+
})
41874193
db.commit()
4188-
return json_response({"ok": True, "resolution": resolution})
4194+
return json_response({
4195+
"ok": True,
4196+
"resolution": resolution,
4197+
"mode": "manual_settlement_recorded",
4198+
"processor_reference": processor_reference,
4199+
"worker_portion": worker_portion,
4200+
"employer_portion": employer_portion,
4201+
})
41894202

41904203
elif path == "/admin/audit-log" and method == "GET":
41914204
user = authenticate(db)

0 commit comments

Comments
 (0)