Skip to content
Open
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
2 changes: 2 additions & 0 deletions api/management/commands/follow_invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ def send_ln_payments(self):
order.trade_escrow.status == LNPayment.Status.SETLED
and order.is_swap is False
):
lnpayment.status = LNPayment.Status.QUEUED
lnpayment.save(update_fields=["status"])
follow_send_payment.delay(lnpayment.payment_hash)

def send_onchain_payments(self):
Expand Down
18 changes: 18 additions & 0 deletions api/migrations/0058_alter_lnpayment_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-19 02:34

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('api', '0057_robot_webhook_enabled_alter_order_escrow_duration'),
]

operations = [
migrations.AlterField(
model_name='lnpayment',
name='status',
field=models.PositiveSmallIntegerField(choices=[(0, 'Generated'), (1, 'Locked'), (2, 'Settled'), (3, 'Returned'), (4, 'Cancelled'), (5, 'Expired'), (6, 'Valid'), (7, 'In flight'), (8, 'Succeeded'), (9, 'Routing failed'), (10, 'Queued')], default=0),
),
]
1 change: 1 addition & 0 deletions api/models/ln_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Status(models.IntegerChoices):
FLIGHT = 7, "In flight"
SUCCED = 8, "Succeeded"
FAILRO = 9, "Routing failed"
QUEUED = 10, "Queued"

class FailureReason(models.IntegerChoices):
NOTYETF = 0, "Payment isn't failed (yet)"
Expand Down
81 changes: 81 additions & 0 deletions api/tests/test_follow_invoices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.contrib.auth.models import User
from django.utils import timezone
from api.models import LNPayment, Order
from api.management.commands.follow_invoices import Command


class TestFollowInvoices(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="maker", password="password")
self.escrow_user = User.objects.create_user(username="escrow", password="password")

now = timezone.now()
# Create escrow payment with SETLED status
self.escrow_payment = LNPayment.objects.create(
payment_hash="abc123escrow",
type=LNPayment.Types.HOLD,
concept=LNPayment.Concepts.TRESCROW,
status=LNPayment.Status.SETLED,
num_satoshis=100000,
sender=self.user,
receiver=self.escrow_user,
created_at=now,
expires_at=now + timedelta(hours=1),
)

# Create payout payment (norm type, FLIGHT status)
self.payout_payment = LNPayment.objects.create(
payment_hash="abc123payout",
type=LNPayment.Types.NORM,
concept=LNPayment.Concepts.PAYBUYER,
status=LNPayment.Status.FLIGHT,
num_satoshis=99000,
sender=self.escrow_user,
receiver=self.user,
created_at=now,
expires_at=now + timedelta(hours=1),
)

# Create order linking escrow and payout
self.order = Order.objects.create(
maker=self.user,
trade_escrow=self.escrow_payment,
payout=self.payout_payment,
is_swap=False,
type=Order.Types.BUY,
expires_at=now + timedelta(hours=1),
)

@patch("api.management.commands.follow_invoices.follow_send_payment")
def test_send_ln_payments_queues_once(self, mock_follow_send_payment):
"""
Validates that send_ln_payments enqueues the payment once and updates
its status to QUEUED, preventing duplicate enqueuing on subsequent polls
(which would happen in main where status remained FLIGHT).
"""
command = Command()

# First run of send_ln_payments
command.send_ln_payments()

# Verify task was called once
mock_follow_send_payment.delay.assert_called_once_with(self.payout_payment.payment_hash)

# Verify payment status changed to QUEUED
self.payout_payment.refresh_from_db()
if hasattr(LNPayment.Status, "QUEUED"):
self.assertEqual(self.payout_payment.status, LNPayment.Status.QUEUED)
else:
# If QUEUED status is not defined, we can skip this assertion
pass

# Second run of send_ln_payments (simulating subsequent poll when workers are stuck)
mock_follow_send_payment.reset_mock()
command.send_ln_payments()

# Verify task was NOT called again (only enqueued once)
mock_follow_send_payment.delay.assert_not_called()