sub-amigo 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. sub_amigo-0.1.0/.github/workflows/ci.yml +32 -0
  2. sub_amigo-0.1.0/.github/workflows/publish.yml +45 -0
  3. sub_amigo-0.1.0/.gitignore +24 -0
  4. sub_amigo-0.1.0/LICENSE +21 -0
  5. sub_amigo-0.1.0/PKG-INFO +331 -0
  6. sub_amigo-0.1.0/README.md +308 -0
  7. sub_amigo-0.1.0/conftest.py +4 -0
  8. sub_amigo-0.1.0/pyproject.toml +49 -0
  9. sub_amigo-0.1.0/sub_amigo/__init__.py +8 -0
  10. sub_amigo-0.1.0/sub_amigo/adapters/__init__.py +3 -0
  11. sub_amigo-0.1.0/sub_amigo/adapters/base.py +61 -0
  12. sub_amigo-0.1.0/sub_amigo/admin.py +44 -0
  13. sub_amigo-0.1.0/sub_amigo/apps.py +7 -0
  14. sub_amigo-0.1.0/sub_amigo/engine/__init__.py +3 -0
  15. sub_amigo-0.1.0/sub_amigo/engine/reminder_engine.py +272 -0
  16. sub_amigo-0.1.0/sub_amigo/exceptions.py +10 -0
  17. sub_amigo-0.1.0/sub_amigo/management/__init__.py +0 -0
  18. sub_amigo-0.1.0/sub_amigo/management/commands/__init__.py +0 -0
  19. sub_amigo-0.1.0/sub_amigo/management/commands/process_reminders.py +67 -0
  20. sub_amigo-0.1.0/sub_amigo/migrations/0001_initial.py +227 -0
  21. sub_amigo-0.1.0/sub_amigo/migrations/__init__.py +0 -0
  22. sub_amigo-0.1.0/sub_amigo/models/__init__.py +12 -0
  23. sub_amigo-0.1.0/sub_amigo/models/notification_log.py +58 -0
  24. sub_amigo-0.1.0/sub_amigo/models/reminder_rule.py +40 -0
  25. sub_amigo-0.1.0/sub_amigo/models/subscription.py +102 -0
  26. sub_amigo-0.1.0/sub_amigo/py.typed +0 -0
  27. sub_amigo-0.1.0/sub_amigo/service.py +128 -0
  28. sub_amigo-0.1.0/sub_amigo/signals.py +23 -0
  29. sub_amigo-0.1.0/tests/__init__.py +0 -0
  30. sub_amigo-0.1.0/tests/settings.py +15 -0
  31. sub_amigo-0.1.0/tests/test_engine.py +216 -0
  32. sub_amigo-0.1.0/tests/test_models.py +82 -0
  33. sub_amigo-0.1.0/tests/test_service.py +136 -0
  34. sub_amigo-0.1.0/tests/test_signals.py +140 -0
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ name: Python ${{ matrix.python-version }} / Django ${{ matrix.django-version }}
12
+ runs-on: ubuntu-latest
13
+
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.14"]
18
+ django-version: ["6.0"]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v5
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+
28
+ - name: Install dependencies
29
+ run: uv sync --dev
30
+
31
+ - name: Run tests
32
+ run: uv run pytest
@@ -0,0 +1,45 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ name: Build distribution
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v5
17
+ with:
18
+ python-version: "3.14"
19
+
20
+ - name: Build
21
+ run: uv build
22
+
23
+ - name: Upload dist
24
+ uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ name: Publish to PyPI
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write
36
+
37
+ steps:
38
+ - name: Download dist
39
+ uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+
44
+ - name: Publish to PyPI
45
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .eggs/
11
+
12
+ # uv
13
+ .venv/
14
+ uv.lock
15
+
16
+ # Testing
17
+ .pytest_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # IDE
22
+ .idea/
23
+ .vscode/
24
+ *.swp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayo Akenroye
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,331 @@
1
+ Metadata-Version: 2.4
2
+ Name: sub-amigo
3
+ Version: 0.1.0
4
+ Summary: A self-hosted, open-source subscription tracking and alert engine for Django.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: billing,django,notifications,reminders,subscriptions
8
+ Classifier: Framework :: Django
9
+ Classifier: Framework :: Django :: 6.0
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: django>=6.0
17
+ Requires-Dist: python-dateutil>=2.9
18
+ Provides-Extra: dev
19
+ Requires-Dist: factory-boy>=3.3; extra == 'dev'
20
+ Requires-Dist: pytest-django>=4.9; extra == 'dev'
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # sub-amigo
25
+
26
+ 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.
27
+
28
+ ---
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ uv add sub-amigo
34
+ # or
35
+ pip install sub-amigo
36
+ ```
37
+
38
+ Add to `INSTALLED_APPS` and run migrations:
39
+
40
+ ```python
41
+ INSTALLED_APPS = [
42
+ ...
43
+ "sub_amigo",
44
+ ]
45
+ ```
46
+
47
+ ```bash
48
+ python manage.py migrate
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Write an adapter
54
+
55
+ Subclass `BaseNotificationAdapter` and implement `send()`. Return `True` on success, `False` on failure. Do not raise exceptions.
56
+
57
+ ```python
58
+ # myapp/adapters.py
59
+ import logging
60
+ from django.core.mail import send_mail
61
+ from django.conf import settings
62
+ from sub_amigo import BaseNotificationAdapter, NotificationPayload
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ class EmailAdapter(BaseNotificationAdapter):
68
+ def send(self, payload: NotificationPayload) -> bool:
69
+ try:
70
+ send_mail(
71
+ subject=payload.subject,
72
+ message=payload.message,
73
+ from_email=settings.DEFAULT_FROM_EMAIL,
74
+ recipient_list=[payload.recipient],
75
+ )
76
+ return True
77
+ except Exception:
78
+ logger.exception("Reminder failed for %s", payload.recipient)
79
+ return False
80
+ ```
81
+
82
+ `NotificationPayload` has four fields:
83
+
84
+ | Field | Description |
85
+ |-------|-------------|
86
+ | `recipient` | The value from `Subscription.recipient_ref`, passed through unchanged |
87
+ | `subject` | Pre-rendered subject line |
88
+ | `message` | Fully-rendered body text |
89
+ | `metadata` | Dict with `subscription_id`, `billing_date`, `days_before`, `cost`, `currency` |
90
+
91
+ ---
92
+
93
+ ## Configure
94
+
95
+ ```python
96
+ # settings.py
97
+ SUB_AMIGO_ADAPTER = "myapp.adapters.EmailAdapter"
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Schedule
103
+
104
+ Run once a day via cron or a task scheduler:
105
+
106
+ ```bash
107
+ python manage.py process_reminders
108
+ ```
109
+
110
+ To process a specific date instead of today:
111
+
112
+ ```bash
113
+ python manage.py process_reminders --date 2024-08-01
114
+ ```
115
+
116
+ With Celery beat:
117
+
118
+ ```python
119
+ # celery.py
120
+ app.conf.beat_schedule = {
121
+ "process-reminders": {
122
+ "task": "myapp.tasks.process_reminders",
123
+ "schedule": crontab(hour=8, minute=0),
124
+ },
125
+ }
126
+ ```
127
+
128
+ ```python
129
+ # myapp/tasks.py
130
+ from celery import shared_task
131
+ from sub_amigo.engine import SubscriptionReminderEngine
132
+ from myapp.adapters import EmailAdapter
133
+
134
+
135
+ @shared_task
136
+ def process_reminders():
137
+ SubscriptionReminderEngine(adapter=EmailAdapter()).process_daily_reminders()
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Create subscriptions
143
+
144
+ 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.
145
+
146
+ ```python
147
+ from datetime import date
148
+ from decimal import Decimal
149
+ from sub_amigo.service import SubAmigo
150
+ from myapp.adapters import EmailAdapter
151
+
152
+ amigo = SubAmigo(adapter=EmailAdapter())
153
+
154
+ sub = amigo.subscribe(
155
+ name="GitHub Teams",
156
+ cost=Decimal("4.00"),
157
+ currency="USD",
158
+ next_billing_date=date(2024, 8, 1),
159
+ recipient_ref="billing@example.com",
160
+ owner_ref="team-42",
161
+ remind_days_before=[7, 3, 0], # uses the default template
162
+ )
163
+
164
+ # Run reminders (call this daily)
165
+ result = amigo.process_reminders()
166
+ ```
167
+
168
+ For custom message templates per rule, use `reminder_specs` instead:
169
+
170
+ ```python
171
+ from sub_amigo.service import ReminderSpec
172
+
173
+ sub = amigo.subscribe(
174
+ name="GitHub Teams",
175
+ cost=Decimal("4.00"),
176
+ next_billing_date=date(2024, 8, 1),
177
+ recipient_ref="billing@example.com",
178
+ reminder_specs=[
179
+ ReminderSpec(days_before=7, message_template="One week until {subscription_name} renews."),
180
+ ReminderSpec(days_before=0, message_template="{subscription_name} charges today ({cost} {currency})."),
181
+ ],
182
+ )
183
+ ```
184
+
185
+ Both arguments can be mixed. `remind_days_before` uses the default template; `reminder_specs` uses whatever you provide.
186
+
187
+ ### Template variables
188
+
189
+ | Variable | Value |
190
+ |----------|-------|
191
+ | `{subscription_name}` | Name of the subscription |
192
+ | `{cost}` | Billing amount |
193
+ | `{currency}` | ISO 4217 currency code |
194
+ | `{next_billing_date}` | Next billing date, `YYYY-MM-DD` |
195
+ | `{days_before}` | Days until billing |
196
+ | `{description}` | Subscription description, may be empty |
197
+
198
+ ---
199
+
200
+ ## Advance the billing date
201
+
202
+ After a subscription charges, move it to the next cycle:
203
+
204
+ ```python
205
+ sub.advance_billing_date().save()
206
+ ```
207
+
208
+ | Interval | Behaviour |
209
+ |----------|-----------|
210
+ | `monthly` | One calendar month forward, month-end dates handled correctly |
211
+ | `annually` | One calendar year forward |
212
+ | `custom` | Forward by `billing_interval_days` days |
213
+
214
+ ---
215
+
216
+ ## Signals
217
+
218
+ sub-amigo fires Django signals after every reminder attempt so your app can react without subclassing anything.
219
+
220
+ ```python
221
+ from django.dispatch import receiver
222
+ from sub_amigo.signals import reminder_sent, reminder_failed
223
+
224
+ @receiver(reminder_sent)
225
+ def log_to_datadog(sender, subscription, rule, payload, log, **kwargs):
226
+ datadog.increment("reminders.sent", tags=[f"sub:{subscription.name}"])
227
+
228
+ @receiver(reminder_failed)
229
+ def alert_on_call(sender, subscription, rule, error, log, **kwargs):
230
+ pagerduty.trigger(f"{subscription.name} reminder failed: {error}")
231
+ ```
232
+
233
+ `reminder_sent` kwargs:
234
+
235
+ | kwarg | Type |
236
+ |-------|------|
237
+ | `subscription` | `Subscription` |
238
+ | `rule` | `ReminderRule` |
239
+ | `payload` | `NotificationPayload` |
240
+ | `log` | `NotificationLog` |
241
+
242
+ `reminder_failed` kwargs:
243
+
244
+ | kwarg | Type |
245
+ |-------|------|
246
+ | `subscription` | `Subscription` |
247
+ | `rule` | `ReminderRule` |
248
+ | `error` | `str` |
249
+ | `log` | `NotificationLog` |
250
+
251
+ `reminder_failed` fires on both adapter failures and template render errors.
252
+
253
+ ---
254
+
255
+ ## Idempotency
256
+
257
+ `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.
258
+
259
+ ---
260
+
261
+ ## Model reference
262
+
263
+ ### Subscription
264
+
265
+ | Field | Type | Notes |
266
+ |-------|------|-------|
267
+ | `id` | UUID | Auto-generated |
268
+ | `name` | CharField | |
269
+ | `cost` | DecimalField | |
270
+ | `currency` | CharField | ISO 4217, default `"USD"` |
271
+ | `billing_interval` | CharField | `monthly` / `annually` / `custom` |
272
+ | `billing_interval_days` | PositiveIntegerField | Required when `billing_interval = "custom"`, min 1 |
273
+ | `next_billing_date` | DateField | |
274
+ | `status` | CharField | `active` / `paused` / `cancelled` |
275
+ | `recipient_ref` | CharField | Passed to your adapter unchanged |
276
+ | `owner_ref` | CharField | Optional, for multi-tenant filtering |
277
+ | `metadata` | JSONField | Arbitrary app-specific data |
278
+
279
+ ### ReminderRule
280
+
281
+ | Field | Type | Notes |
282
+ |-------|------|-------|
283
+ | `subscription` | ForeignKey | Cascade delete |
284
+ | `days_before` | PositiveSmallIntegerField | `0` fires on the billing date; unique per subscription |
285
+ | `message_template` | TextField | `str.format_map()` template |
286
+ | `is_active` | BooleanField | |
287
+
288
+ ### NotificationLog
289
+
290
+ Read-only ledger. One row per reminder fired.
291
+
292
+ ---
293
+
294
+ ## Direct model access
295
+
296
+ For data migrations, bulk imports, management commands, or admin actions, you can work with the ORM directly.
297
+
298
+ ```python
299
+ from sub_amigo.models import Subscription, ReminderRule, BillingInterval
300
+
301
+ sub = Subscription.objects.create(
302
+ name="GitHub Teams",
303
+ cost="4.00",
304
+ currency="USD",
305
+ billing_interval=BillingInterval.MONTHLY,
306
+ next_billing_date=date(2024, 8, 1),
307
+ recipient_ref="billing@example.com",
308
+ )
309
+
310
+ ReminderRule.objects.create(
311
+ subscription=sub,
312
+ days_before=7,
313
+ message_template="{subscription_name} renews on {next_billing_date}.",
314
+ )
315
+ ```
316
+
317
+ `SubAmigo.subscribe()` does the same thing internally. Reach for the ORM when you need finer control or are operating outside a request cycle.
318
+
319
+ ---
320
+
321
+ ## Requirements
322
+
323
+ - Python 3.14+
324
+ - Django 6.0+
325
+ - python-dateutil 2.9+
326
+
327
+ ---
328
+
329
+ ## Licence
330
+
331
+ MIT