diff --git a/liberapay/constants.py b/liberapay/constants.py index 22d5d1055..8d84d7755 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -128,6 +128,7 @@ def generate_value(self, currency): Event('upcoming_debit', 2**14, _("When an automatic donation renewal payment is upcoming")), Event('missing_route', 2**15, _("When I no longer have any valid payment instrument")), Event('renewal_aborted', 2**16, _("When a donation renewal payment has been aborted")), + Event('income_has_passed_goal', 2**16, _("When income has surpassed goal")), ] check_bits([e.bit for e in EVENTS]) EVENTS = {e.name: e for e in EVENTS} diff --git a/liberapay/main.py b/liberapay/main.py index b1c37d127..d9f145e67 100644 --- a/liberapay/main.py +++ b/liberapay/main.py @@ -203,6 +203,7 @@ def default_body_parser(body_bytes, headers): cron(intervals.get('execute_reviewed_payins', 3600), execute_reviewed_payins, True) cron('irregular', website.cryptograph.rotate_stored_data, True) + cron(Weekly(weekday=3, hour=1), Participant.check_income_goals, True) # Website Algorithm diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index a12913d56..1569b5539 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -3111,6 +3111,78 @@ def find_partial_match(new_sp, current_schedule_map): ) return new_schedule + + + # Check Income Goals + def check_income_goals(self): + """Check if the participant's income over the past four weeks meets or exceeds their weekly income goal.""" + four_weeks_ago = utcnow() - FOUR_WEEKS + received_income = self.get_received_income(self.db, four_weeks_ago, utcnow()) + + if received_income >= self.weekly_income_goal * 4: # Assume the goal is weekly + if not self.has_recent_notification(self.db): + self.send_income_goal_met_notification(self.db) + + def get_received_income(self, start_date, end_date, save = True): + with self.db.get_cursor() as cursor: + # Prevent race conditions + if save: + cursor.run("SELECT * FROM participants WHERE id = %s FOR UPDATE", + (self.id,)) + """Retrieve the total income received by this participant between two dates.""" + query = cursor.all(""" + SELECT COALESCE(SUM(amount), 0) FROM transfers + WHERE recipient = {user_id} + AND timestamp BETWEEN {start_date} AND {end_date} + """).format( + user_id=self.id, + start_date=start_date, + end_date=end_date + ) + return self.db.one(query) + + def has_recent_notification(self): + """Check if a notification has been sent to this participant in the past week.""" + query = self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = {user_id} + AND notified_date > CURRENT_DATE - INTERVAL '1 week' + ) + """).format(user_id=self.id) + return self.db.one(query) + + def send_income_goal_met_notification(self, save = True): + """Send a notification and record it in the database.""" + notify = False + if notify: + sp_to_dict = lambda sp: { + 'amount': sp.amount, + 'execution_date': sp.execution_date, + } + self.notify( + '"Your income has met your set goal!"', + force_email=True, + added_payments=[sp_to_dict(new_sp) for new_sp in insertions], + cancelled_payments=[sp_to_dict(old_sp) for old_sp in deletions], + modified_payments=[t for t in ( + (sp_to_dict(old_sp), sp_to_dict(new_sp)) + for old_sp, new_sp in updates + if old_sp.notifs_count > 0 + ) if t[0] != t[1]], + new_schedule=new_schedule, + ) + + + # Record the notification being sent in the database + query = self.db(""" + INSERT INTO income_notifications (user_id, notified_date) + VALUES ({user_id}, CURRENT_TIMESTAMP) + """).format(user_id=self.id) + self.db.run(query) + + + def get_tip_to(self, tippee, currency=None): diff --git a/sql/schema.sql b/sql/schema.sql index c2171dc71..e66f34828 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -168,6 +168,19 @@ CREATE TRIGGER update_profile_visibility BEFORE INSERT OR UPDATE ON participants FOR EACH ROW EXECUTE PROCEDURE update_profile_visibility(); +-- adding a new column for the income goal +ALTER TABLE participants ADD COLUMN weekly_income_goal numeric(10, 2); + +-- Create a new table to store income notifications +CREATE TABLE income_notifications ( + id serial PRIMARY KEY, + user_id bigint NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + notified_date timestamp with time zone NOT NULL DEFAULT current_timestamp, + UNIQUE (user_id, notified_date) -- Ensure no duplicate notifications for the same date +); + +-- Index for quick lookup by user_id +CREATE INDEX idx_income_notifications_user_id ON income_notifications(user_id); -- settings specific to users who want to receive donations diff --git a/tests/py/test_incomegoals.py b/tests/py/test_incomegoals.py new file mode 100644 index 000000000..40a447a31 --- /dev/null +++ b/tests/py/test_incomegoals.py @@ -0,0 +1,67 @@ +import pytest +from liberapay.testing import Harness +from liberapay.models.participant import Participant +from datetime import timedelta +from liberapay.i18n.currencies import Money + +class TestIncomeGoalChecks(Harness): + + def setUp(self): + super(TestIncomeGoalChecks, self).setUp() + self.alice = self.make_participant('alice', weekly_income_goal=Money('100.00', 'EUR')) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=1))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=2))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=3))) + self.db.run(""" + INSERT INTO transfers (recipient, amount, timestamp) + VALUES (%s, %s, %s) + """, (self.alice.id, Money('25.00', 'EUR'), self.utcnow() - timedelta(weeks=4))) + + def test_income_goal_met_and_notification_sent(self): + # Test income goal met and notification sent correctly + self.alice.check_income_goals() + assert self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = %s + ) + """, (self.alice.id,)) is True + + def test_income_goal_not_met(self): + # Adjust one payment to simulate failing to meet the goal + self.db.run(""" + UPDATE transfers SET amount = %s WHERE timestamp = %s + """, (Money('15.00', 'EUR'), self.utcnow() - timedelta(weeks=1))) + self.alice.check_income_goals() + assert self.db.one(""" + SELECT EXISTS( + SELECT 1 FROM income_notifications + WHERE user_id = %s + ) + """, (self.alice.id,)) is False + + def test_notification_not_sent_if_recently_notified(self): + # Simulate a recent notification + self.db.run(""" + INSERT INTO income_notifications (user_id, notified_date) + VALUES (%s, CURRENT_TIMESTAMP) + """, (self.alice.id,)) + self.alice.check_income_goals() + notifications = self.db.all(""" + SELECT * FROM income_notifications WHERE user_id = %s + """, (self.alice.id,)) + assert len(notifications) == 1 # No new notification should be added + +@pytest.fixture(autouse=True) +def setup(db): + db.run("CREATE TEMPORARY TABLE transfers (recipient int, amount money, timestamp timestamp)") + db.run("CREATE TEMPORARY TABLE income_notifications (user_id int, notified_date timestamp)") diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py index 444dd4a99..2a59ca045 100644 --- a/tests/py/test_participant.py +++ b/tests/py/test_participant.py @@ -517,3 +517,5 @@ def test_rs_returns_openstreetmap_url_for_stub_from_openstreetmap(self): stub = Participant.from_username(unclaimed.participant.username) actual = stub.resolve_stub() assert actual == "/on/openstreetmap/alice/" + +