A self-hosted, open-source subscription tracking and alert engine for Django.
Project description
sub-amigo
A Django app that tracks software subscriptions and fires reminders before they renew. It works out when to call your notification adapter and what to pass it. You write the adapter.
Install
uv add sub-amigo
# or
pip install sub-amigo
Add to INSTALLED_APPS and run migrations:
INSTALLED_APPS = [
...
"sub_amigo",
]
python manage.py migrate
Write an adapter
Subclass BaseNotificationAdapter and implement send(). Return True on success, False on failure. Do not raise exceptions.
# myapp/adapters.py
import logging
from django.core.mail import send_mail
from django.conf import settings
from sub_amigo import BaseNotificationAdapter, NotificationPayload
logger = logging.getLogger(__name__)
class EmailAdapter(BaseNotificationAdapter):
def send(self, payload: NotificationPayload) -> bool:
try:
send_mail(
subject=payload.subject,
message=payload.message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[payload.recipient],
)
return True
except Exception:
logger.exception("Reminder failed for %s", payload.recipient)
return False
NotificationPayload has four fields:
| Field | Description |
|---|---|
recipient |
The value from Subscription.recipient_ref, passed through unchanged |
subject |
Pre-rendered subject line |
message |
Fully-rendered body text |
metadata |
Dict with subscription_id, billing_date, days_before, cost, currency |
Configure
# settings.py
SUB_AMIGO_ADAPTER = "myapp.adapters.EmailAdapter"
Schedule
Run once a day via cron or a task scheduler:
python manage.py process_reminders
To process a specific date instead of today:
python manage.py process_reminders --date 2024-08-01
With Celery beat:
# celery.py
app.conf.beat_schedule = {
"process-reminders": {
"task": "myapp.tasks.process_reminders",
"schedule": crontab(hour=8, minute=0),
},
}
# myapp/tasks.py
from celery import shared_task
from sub_amigo.engine import SubscriptionReminderEngine
from myapp.adapters import EmailAdapter
@shared_task
def process_reminders():
SubscriptionReminderEngine(adapter=EmailAdapter()).process_daily_reminders()
Processing result
process_reminders() and process_daily_reminders() both return a ProcessingResult:
result = amigo.process_reminders()
print(result.succeeded) # reminders sent
print(result.failed) # adapter returned False or template error
print(result.skipped) # already sent for this billing cycle
print(result.processed) # total rules evaluated
| Field | Type | Description |
|---|---|---|
run_date |
date |
The date the engine ran against |
processed |
int |
Total rules evaluated |
succeeded |
int |
Reminders sent successfully |
failed |
int |
Adapter failures or template errors |
skipped |
int |
Already sent for this billing cycle |
results |
list[ReminderResult] |
Per-rule detail |
Each ReminderResult has rule_id, subscription_id, subscription_name, success, skipped, and error.
Create subscriptions
The SubAmigo class is the quickest way to get started. It creates the subscription and its reminder rules in one call and exposes process_reminders() on the same object.
from datetime import date
from decimal import Decimal
from sub_amigo.service import SubAmigo
from myapp.adapters import EmailAdapter
amigo = SubAmigo(adapter=EmailAdapter())
sub = amigo.subscribe(
name="GitHub Teams",
cost=Decimal("4.00"),
currency="USD",
next_billing_date=date(2024, 8, 1),
recipient_ref="billing@example.com",
owner_ref="team-42",
remind_days_before=[7, 3, 0], # uses the default template
)
# Run reminders (call this daily)
result = amigo.process_reminders()
For custom message templates per rule, use reminder_specs instead:
from sub_amigo.service import ReminderSpec
sub = amigo.subscribe(
name="GitHub Teams",
cost=Decimal("4.00"),
next_billing_date=date(2024, 8, 1),
recipient_ref="billing@example.com",
reminder_specs=[
ReminderSpec(days_before=7, message_template="One week until {subscription_name} renews."),
ReminderSpec(days_before=0, message_template="{subscription_name} charges today ({cost} {currency})."),
],
)
Both arguments can be mixed. remind_days_before uses the default template; reminder_specs uses whatever you provide.
Template variables
| Variable | Value |
|---|---|
{subscription_name} |
Name of the subscription |
{cost} |
Billing amount |
{currency} |
ISO 4217 currency code |
{next_billing_date} |
Next billing date, YYYY-MM-DD |
{days_before} |
Days until billing |
{description} |
Subscription description, may be empty |
Advance the billing date
After a subscription charges, move it to the next cycle:
sub.advance_billing_date().save()
| Interval | Behaviour |
|---|---|
monthly |
One calendar month forward, month-end dates handled correctly |
annually |
One calendar year forward |
custom |
Forward by billing_interval_days days |
Signals
sub-amigo fires Django signals after every reminder attempt so your app can react without subclassing anything.
from django.dispatch import receiver
from sub_amigo.signals import reminder_sent, reminder_failed
@receiver(reminder_sent)
def log_to_datadog(sender, subscription, rule, payload, log, **kwargs):
datadog.increment("reminders.sent", tags=[f"sub:{subscription.name}"])
@receiver(reminder_failed)
def alert_on_call(sender, subscription, rule, error, log, **kwargs):
pagerduty.trigger(f"{subscription.name} reminder failed: {error}")
reminder_sent kwargs:
| kwarg | Type |
|---|---|
subscription |
Subscription |
rule |
ReminderRule |
payload |
NotificationPayload |
log |
NotificationLog |
reminder_failed kwargs:
| kwarg | Type |
|---|---|
subscription |
Subscription |
rule |
ReminderRule |
error |
str |
log |
NotificationLog |
reminder_failed fires on both adapter failures and template render errors.
Idempotency
NotificationLog records every fired reminder keyed on (reminder_rule, billing_cycle_date). That pair has a unique constraint, so running process_daily_reminders() twice on the same date does not send duplicates. The engine checks before calling the adapter; the database constraint enforces it regardless.
Model reference
Subscription
| Field | Type | Notes |
|---|---|---|
id |
UUID | Auto-generated |
name |
CharField | |
cost |
DecimalField | |
currency |
CharField | ISO 4217, default "USD" |
billing_interval |
CharField | monthly / annually / custom |
billing_interval_days |
PositiveIntegerField | Required when billing_interval = "custom", min 1 |
next_billing_date |
DateField | |
status |
CharField | active / paused / cancelled |
recipient_ref |
CharField | Passed to your adapter unchanged |
owner_ref |
CharField | Optional, for multi-tenant filtering |
metadata |
JSONField | Arbitrary app-specific data |
ReminderRule
| Field | Type | Notes |
|---|---|---|
subscription |
ForeignKey | Cascade delete |
days_before |
PositiveSmallIntegerField | 0 fires on the billing date; unique per subscription |
message_template |
TextField | str.format_map() template |
is_active |
BooleanField |
NotificationLog
Read-only ledger. One row per reminder fired.
Direct model access
For data migrations, bulk imports, management commands, or admin actions, you can work with the ORM directly.
from sub_amigo.models import Subscription, ReminderRule, BillingInterval
sub = Subscription.objects.create(
name="GitHub Teams",
cost="4.00",
currency="USD",
billing_interval=BillingInterval.MONTHLY,
next_billing_date=date(2024, 8, 1),
recipient_ref="billing@example.com",
)
ReminderRule.objects.create(
subscription=sub,
days_before=7,
message_template="{subscription_name} renews on {next_billing_date}.",
)
SubAmigo.subscribe() does the same thing internally. Reach for the ORM when you need finer control or are operating outside a request cycle.
Requirements
- Python 3.12+
- Django 5.0+
- python-dateutil 2.9+
Licence
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sub_amigo-0.1.2.tar.gz.
File metadata
- Download URL: sub_amigo-0.1.2.tar.gz
- Upload date:
- Size: 16.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
324cd8062c618f50f4c0a25238f4e37ac7387031a1b336a6c4dbedc20c91eb10
|
|
| MD5 |
0fbd6df8dd2de7862485cc09b4a47d06
|
|
| BLAKE2b-256 |
c2e6013a72274051d5b5bbae8db3940a50c35de07973fa4dab0490f597dd8bce
|
Provenance
The following attestation bundles were made for sub_amigo-0.1.2.tar.gz:
Publisher:
publish.yml on aayodejii/sub-amigo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sub_amigo-0.1.2.tar.gz -
Subject digest:
324cd8062c618f50f4c0a25238f4e37ac7387031a1b336a6c4dbedc20c91eb10 - Sigstore transparency entry: 1704942998
- Sigstore integration time:
-
Permalink:
aayodejii/sub-amigo@994e791a43d99b9eb083055354c3f340ba9c9d28 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/aayodejii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@994e791a43d99b9eb083055354c3f340ba9c9d28 -
Trigger Event:
release
-
Statement type:
File details
Details for the file sub_amigo-0.1.2-py3-none-any.whl.
File metadata
- Download URL: sub_amigo-0.1.2-py3-none-any.whl
- Upload date:
- Size: 20.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a053addd54ca1c476cdb90f3c1ec0a8446c3a8c1ebff81a4414ec93f02b8571
|
|
| MD5 |
9d398913b2de548f63db9fe8cd74c227
|
|
| BLAKE2b-256 |
1be8e6ab0377261111ea596237911244d810acf1ef6984441d14c71ad4c1ff64
|
Provenance
The following attestation bundles were made for sub_amigo-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on aayodejii/sub-amigo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sub_amigo-0.1.2-py3-none-any.whl -
Subject digest:
2a053addd54ca1c476cdb90f3c1ec0a8446c3a8c1ebff81a4414ec93f02b8571 - Sigstore transparency entry: 1704943030
- Sigstore integration time:
-
Permalink:
aayodejii/sub-amigo@994e791a43d99b9eb083055354c3f340ba9c9d28 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/aayodejii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@994e791a43d99b9eb083055354c3f340ba9c9d28 -
Trigger Event:
release
-
Statement type: