feat: production-hardening pass across the kit

Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
This commit is contained in:
jcv-dev 2026-09-24 10:41:21 -05:00
parent 75c3c7b2c2
commit 551b42eab5
372 changed files with 7523 additions and 2008 deletions

BIN
.coverage

Binary file not shown.

View file

@ -85,6 +85,9 @@ jobs:
- name: Run ruff - name: Run ruff
run: ruff check . run: ruff check .
- name: Check formatting
run: ruff format --check infrasynth/ config/ tests/
typecheck: typecheck:
name: Type Check name: Type Check
runs-on: ubuntu-latest runs-on: ubuntu-latest

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
# Virtual environments
.venv/
venv/
env/
# Test / coverage artifacts
.pytest_cache/
.coverage
.coverage.*
htmlcov/
coverage.xml
# Local databases and env
*.sqlite3
*.db
.env
.env.*
# Tooling caches
.mypy_cache/
.ruff_cache/
# Local media (file storage backend writes here in dev)
media/

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 646
>>
stream
Gasam_/>bs&A@O6bc.>!+SK1O:+oV2VJ=XN9paqj$Y;mtZp;d>C-THh2Jccfh$+7'NM#CIH+&hXW(Hm2.tA-ZS6npN#jDF^'duVf_T-cgHQda3((a+C;_4"s)D**tN)`8oW(!4)BHJ%T8FCLsQAY5WaFs./E`SE5TFB7j\cV=]bI_V\]nQ(bdPLA?ZR"Ipg-gUS[1b9'@Y0Jj1P$hd9IO^m+2PRo"Puq5d>]bpB5=qP%mJn+q^>;<Jfb5-8MB>@qkDBZW\!G<3F)%(-GG:j@V4_;`U`bu9"CZ#`Q*??DL]/imrHZFMN_koNh%js*gkImRr5heg78C*o"Omg<R9tTaIDH/B4R2($4TE]rqGd/":d;f&A]`RD#k/[P_5HHKZ+kq!;>Ss<0mfr5'^5K-?%)j]R81=q6hg1eK_W""dmBH,WI?ebj)?9AZL*\&q[o2!.SeJ%#g9hqkg>Ip3YQ<k6rX@`XFPH"I*RDNd?dBJUkAoeHj>VJ"cUN&Oa=?Hi9"$1^/@5Ik0h=%G%bj1!tcp,U-P;DXq0Um?*=S^r8!J5jij3O?DPGpO2OSk1E(2k$g`PGf@X`PDd7]Q)O]^ZJ>HERP%fO!*_DPc6!.$:U5?Wq=I];qL8h#PG^p=8#W!s(B4BOGVlQ~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<9e52a7c939f2a870451bc8004c9e7d1d><9e52a7c939f2a870451bc8004c9e7d1d>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1657
%%EOF

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 650
>>
stream
Gasam?#Q2d'Rf.Ggo$!/K)-BqE;),i+4I*p;A[o^]eJGH;uo$H[/Tu/gQ4Zed`)V`f>mWUH[H8(UOi,A;ug8\357aE%bW)"#R+a'i&^K/HIsGgK*2XN@NdL+&g?<u&M_OmA4\8DII\cuDKAaBMXkf'eE#`HAE=Y^6?-2SQJgZ]7CR"&O,uLlFp>cnPm#mkR#$i\F<W&*L7faLL;4EW6ceV-T>84J"TE>2E;+Na-X3p2qXEn?+lJ=&J=.M5FX(Uec@=2`;-'Nr]B4(IgRABu*f?AN085P'*u2X-2(1ZN,BN$Xb4I>g7Ys=N2OrD+Zgbc3:9V:S>.7]DCuhpOc)[1r%Z@2F`>P^u^Ht@dakuHUR_Y$g&TJ(IA#nSYN,k<E*&1ofFb'5b3N>^R[m'UhH<mo>92U.Onm^)4:\@9HBEM$b<%/ot?06920(\0e=>33P21TTal@BOfmbB&sK=ua$Fq$X[)BfWV)M!&8PY.[1H]V,KK3sP;&_7ftSc6rA)#*1\bs/tk')W[gY,o\9Z@[diYs-)Q&[m>%`IiS\8hNRd0#hB+2"MJ"O_Z:Wk!"#Sdl9(O\JfJ+hJkDlE])'LV5"",/2i!hVTW$W@'#iB(Q<pO]o;D'TAE4,?SJ@'o'@dr#h:_`pE(1$23.~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<bc6093c212d09b5ce4fbd73a51960790><bc6093c212d09b5ce4fbd73a51960790>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1661
%%EOF

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 646
>>
stream
Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<6708953b0665daa52c06093e6e045b34><6708953b0665daa52c06093e6e045b34>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1657
%%EOF

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 680
>>
stream
Gat=(9lldX&A@sBm&<?."4]"j+SX;BBfbLcRAQiFog1CTO>/*$GhSd^PlqTL`6HdM,L,LfhqP%>"MkQ72m33FV"!LZ!=@1Y5T**`^VIm3p3VN7;@/0,J2nC<5ckS!U0'n?LJ#LAK][0lM/])VZW*H]!#(Q_aCHS*"0%-TdhR+o=l/S8lntU5OIh"fRUlLYgdWJeXIP(YP9Hl,\08)0_@R(/RQL#A/V3Y[@WJ6Sm;1#_lG;$DdO.?'(ZpKrhFMrbk:MTd?<1XBC$XgPDJeBkW%Va\^61tb@8oj#<3Q'W+,/Gk;l4cQ2G/jjD#KBb0FNrcC5Zr@gD8&j!ZQDdr6;6sFV3\WFc&!'a^KmlUPf%rFuBa=(!)KPB8oUd[qD5K2!9Z,X/uKRS^0T-$HW))>nl&+.3J-O<Lr6`[WoD%a;lh)"9Y2k^[+);[,icA9nT8R%+[T:CX$L"J=P=P`Q_f*2>)(:&1Pu;LZ(%^6nr]2JYWbYr)d24Yf>hOr4ej-;.o8^s8<U,_4TTAau[b!2(WQ;F<NArLaS"J4BfdPjC15j0o?stoQsueSsc89]AUrPZ&-E;`J?kNJr>2TMda%DOkoo?f_oRN2I"MD=?#fr*s*fc1B%M=nTMklq:5'7*-;bOR!&ItU6YR$[,]1lo!9ce8"7)AUZ@5VJEiM1p'A)i9ge~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<50bd4355e2b23b12206fd030ee2a05ef><50bd4355e2b23b12206fd030ee2a05ef>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1691
%%EOF

View file

@ -2,9 +2,13 @@
## Project Overview ## Project Overview
InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It provides 9 Django apps that cover authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code. InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It is the **one shared kit** every Infrasynth app depends on, providing 10 Django apps that cover tenancy, authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code.
**One version, one repo, one pip install.** Feature flags control what is active per tenant/user. **Every app is multi-tenant.** One deployment per app serves all customers; a customer is a **tenant** (workspace), isolated at row level via `tenant_id` on a shared schema. Read `../TENANCY.md` — it is the source of truth for tenancy.
**No license server.** Everything runs on our own infrastructure, so there are no signed license keys, no phone-home, no offline SDK, and no validation grace period. What a tenant may use is an **entitlement**, enforced in-process by `infrasynth.billing`. Read `../ENTITLEMENTS.md`.
**One version, one repo, one pip install.** Feature flags are operational toggles per tenant/user; entitlements are commercial rights.
--- ---
@ -40,6 +44,8 @@ infrasynth-base/
│ │
├── infrasynth/ # Namespace package root ├── infrasynth/ # Namespace package root
│ ├── shared/ # NOT a Django app. Zero-Django utilities. │ ├── shared/ # NOT a Django app. Zero-Django utilities.
│ ├── api/ # DRF API layer (envelope, camelCase, cursor pagination, request-id)
│ ├── tenancy/ # Django app: 'infrasynth.tenancy' (Tenant, membership, scoping)
│ ├── audit/ # Django app: 'infrasynth.audit' │ ├── audit/ # Django app: 'infrasynth.audit'
│ ├── security/ # Django app: 'infrasynth.security' │ ├── security/ # Django app: 'infrasynth.security'
│ ├── files/ # Django app: 'infrasynth.files' │ ├── files/ # Django app: 'infrasynth.files'
@ -73,6 +79,19 @@ infrasynth-base/
- `infrasynth.shared.*` (protocols, enums, crypto, types) - `infrasynth.shared.*` (protocols, enums, crypto, types)
- Django stdlib (`django.db.models`, `django.conf.settings`, `django.dispatch.Signal`) - Django stdlib (`django.db.models`, `django.conf.settings`, `django.dispatch.Signal`)
### 1b. Multi-Tenancy First — read `../TENANCY.md` before any model
Every app is multi-tenant. The kit's `infrasynth.tenancy` app provides the tenant model, membership, request context, and scoped managers. Non-negotiables:
- Every **tenant-owned** model has a non-null `tenant` FK, `objects = TenantManager()` and `all_objects = AllObjectsManager()`.
- No tenant context ⇒ the scoped manager returns an **empty queryset** (fail closed). A query that works without a tenant is a bug.
- Cross-tenant object access returns **`404`, never `403`**.
- Uniqueness that was global becomes unique **per tenant**; indexes lead with `tenant_id`.
- `unsafe_all()` is never called from a view.
- Celery tasks and signals carry `tenant_id` explicitly; cache keys are prefixed `tenant:{id}:`.
See `../TENANCY.md` §4 for the full contract and the per-model scoping table in `PLAN.md` §2.0.
### 2. Integration Mechanisms (in priority order) ### 2. Integration Mechanisms (in priority order)
| Mechanism | When to use | Example | | Mechanism | When to use | Example |
@ -92,7 +111,9 @@ infrasynth.shared ← Zero deps (protocols, enums, crypto)
↑ ↑
infrasynth.audit ← shared only infrasynth.audit ← shared only
↑ ↑
All other Django apps ← shared + audit only infrasynth.tenancy ← shared + audit (defines isolation; used by every tenant-owned app)
↑
All other Django apps ← shared + audit (+ tenancy where tenant-owned)
↑ ↑
infrasynth.features ← Used by ALL apps for feature gating infrasynth.features ← Used by ALL apps for feature gating
↑ (but apps register flags, don't import features) ↑ (but apps register flags, don't import features)
@ -143,6 +164,8 @@ app_name/
4. **`settings.AUTH_USER_MODEL`** for user references. Never hardcode `auth.User`. 4. **`settings.AUTH_USER_MODEL`** for user references. Never hardcode `auth.User`.
5. **JSONField for flexible metadata**, not TextField. 5. **JSONField for flexible metadata**, not TextField.
6. **Use `infrasynth.shared.enums`** for choice fields (never hardcode strings in choices). 6. **Use `infrasynth.shared.enums`** for choice fields (never hardcode strings in choices).
7. **Tenant-owned models carry `tenant` + scoped managers:** non-null FK to `tenancy.Tenant`, `objects = TenantManager()`, `all_objects = AllObjectsManager()`. Uniqueness becomes `(tenant, field)` and indexes lead with `tenant_id` (`../TENANCY.md` §4).
8. **Prefer the tenancy mixins over hand-writing the field:** inherit `infrasynth.tenancy.mixins.TenantOwnedModel` (non-null `tenant`, `TenantManager` default, `all_objects`, and save-time tenant auto-assignment) or `GlobalOrTenantModel` (nullable `tenant`, `GlobalOrTenantManager` returning global + current-tenant rows, `resolve()` for precedence). Do not redeclare `tenant`/managers on a model that already inherits a mixin.
### Serializer Conventions ### Serializer Conventions
@ -167,6 +190,7 @@ def initial(self, request, *args, **kwargs):
5. **Pagination:** All list views use the standard `CustomPagination` class. Query param `?page_size=` (default 25, max 100). 5. **Pagination:** All list views use the standard `CustomPagination` class. Query param `?page_size=` (default 25, max 100).
6. **Filtering:** Use `DjangoFilterBackend` with a `FilterSet` class per view. 6. **Filtering:** Use `DjangoFilterBackend` with a `FilterSet` class per view.
7. **Tenant scoping is automatic:** never filter by tenant by hand — `Model.objects` is already scoped to the current tenant. Never call `unsafe_all()` from a view. A cross-tenant id resolves to `404` (the scoped manager makes the row invisible), never `403`.
### Signal Conventions ### Signal Conventions
@ -219,6 +243,9 @@ class MyRegistry:
- `authenticated_client` — APIClient with JWT cookies set - `authenticated_client` — APIClient with JWT cookies set
- `admin_client` — authenticated superuser client - `admin_client` — authenticated superuser client
- `user_factory`, `role_factory`, etc. - `user_factory`, `role_factory`, etc.
- `tenant_factory`, `membership_factory` — for multi-tenant tests
6. **Tenant isolation is mandatory:** create two tenants with data and assert tenant A cannot read, write, update, or delete tenant B's rows, and that cross-tenant access returns `404`. Any Celery task touching tenant data gets a test proving it carries `tenant_id` and does not leak across tenants.
### Settings Conventions ### Settings Conventions
@ -232,6 +259,8 @@ from infrasynth.shared.settings_utils import get_setting
cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True)
``` ```
5. **Tenancy is configured via `INFRASYNTH_TENANCY`** (`TENANT_MODEL`, `TENANT_CLAIM`, `REQUIRE_TENANT_BY_DEFAULT`, allowlist, defaults). Billing/grace via `INFRASYNTH_BILLING` (`GRACE_PERIOD_DAYS`, `DEFAULT_CURRENCY`). Both have safe defaults (`../TENANCY.md`, `../ENTITLEMENTS.md`).
### Crypto Conventions ### Crypto Conventions
1. **Use `infrasynth.shared.crypto`** for Fernet encryption/decryption. 1. **Use `infrasynth.shared.crypto`** for Fernet encryption/decryption.
@ -307,6 +336,40 @@ class TicketDataValidator:
DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) DataValidatorRegistry.register("ticket_approval", TicketDataValidator())
``` ```
### App B needs: a tenant-owned model
```python
# helpdesk/models.py
from infrasynth.tenancy.managers import TenantManager, AllObjectsManager
class Ticket(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
subject = models.CharField(max_length=255)
objects = TenantManager() # always scoped to the current tenant
all_objects = AllObjectsManager() # unscoped — admin/management only
class Meta:
constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")]
```
No view filters by tenant by hand: `Ticket.objects.all()` already returns only the current tenant's tickets, and a foreign tenant's id yields `404`. Never call `unsafe_all()` in a view.
### App B needs: entitlement gating
```python
# helpdesk/views.py
from infrasynth.billing.services import EntitlementService
from infrasynth.shared.exceptions import EntitlementError
def create_ticket(request, tenant):
if not EntitlementService().is_entitled(tenant, "helpdesk", feature="tickets"):
raise EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets")
...
```
Enforcement is server-side and in-process. There is no license key and no offline check (`../ENTITLEMENTS.md`).
--- ---
## Common Patterns and Anti-Patterns ## Common Patterns and Anti-Patterns
@ -323,6 +386,10 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator())
- Encrypt secrets at rest with Fernet - Encrypt secrets at rest with Fernet
- Use `Result[T, E]` monad for service methods that can fail - Use `Result[T, E]` monad for service methods that can fail
- Add `select_related()`/`prefetch_related()` in every view's `get_queryset()` - Add `select_related()`/`prefetch_related()` in every view's `get_queryset()`
- Put `tenant` + `TenantManager` on every tenant-owned model
- Resolve the tenant from the session token or a tenant-scoped credential
- Prefix every cache/Redis/rate-limit key with `tenant:{id}:`
- Carry `tenant_id` explicitly into Celery tasks and signals
### ❌ DON'T ### ❌ DON'T
@ -335,6 +402,10 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator())
- Don't use signals for synchronous request-response flows (use direct method calls) - Don't use signals for synchronous request-response flows (use direct method calls)
- Don't create circular imports — if app A needs app B, and app B needs app A, refactor into shared or use signals - Don't create circular imports — if app A needs app B, and app B needs app A, refactor into shared or use signals
- Don't store file contents in the database — always use the files app's storage abstraction - Don't store file contents in the database — always use the files app's storage abstraction
- Don't resolve a tenant from a client-supplied id on an unauthenticated request
- Don't call `unsafe_all()` from a view
- Don't leave a formerly-global unique field global when it should be unique per tenant
- Don't introduce license keys, a license server, phone-home, or offline verification — use entitlements
--- ---
@ -342,7 +413,16 @@ DataValidatorRegistry.register("ticket_approval", TicketDataValidator())
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `infrasynth/shared/protocols.py` | All ABCs and Protocols | | `infrasynth/shared/protocols.py` | All ABCs and Protocols (incl. `TenantProtocol`) |
| `infrasynth/api/renderers.py` | EnvelopeJSONRenderer (envelope + camelCase) |
| `infrasynth/api/pagination.py` | CursorPagination |
| `infrasynth/api/exceptions.py` | envelope_exception_handler + namespaced error codes |
| `infrasynth/api/middleware.py` | RequestIdMiddleware |
| `infrasynth/tenancy/models.py` | Tenant, TenantMembership |
| `infrasynth/tenancy/managers.py` | TenantManager, AllObjectsManager |
| `infrasynth/tenancy/middleware.py` | TenantMiddleware (resolves the tenant from the token claim) |
| `infrasynth/tenancy/context.py` | `current_tenant` ContextVar |
| `infrasynth/billing/models.py` | App, Plan, Entitlement, Subscription, Invoice, PaymentTransaction |
| `infrasynth/shared/crypto.py` | Fernet encrypt/decrypt/rotation | | `infrasynth/shared/crypto.py` | Fernet encrypt/decrypt/rotation |
| `infrasynth/shared/enums.py` | All shared enums | | `infrasynth/shared/enums.py` | All shared enums |
| `infrasynth/shared/results.py` | Result monad | | `infrasynth/shared/results.py` | Result monad |

62
CHANGELOG.md Normal file
View file

@ -0,0 +1,62 @@
# Changelog
All notable changes to `infrasynth-base` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versions are derived from Conventional Commits by `python-semantic-release`; do
not hand-pick a version (see `../AGENTS.backend-packages.md` §8).
## [Unreleased]
### Added
- **Verified inbound webhooks.** `InboundReceiveView` now enforces the shared
HMAC signature (or a provider-specific `BaseInboundHandler.verify`), payload
size limits, timestamp tolerance, and idempotent re-delivery via
`InboundEvent.external_id`; verified events are dispatched to the endpoint's
handler through `process_inbound_event` and marked `is_verified`/`is_processed`.
- **Working 2FA login flow.** Login now challenges users with a configured
second factor (pre-auth session + cookie) and only mints JWT cookies after
`2fa/verify/` (or `2fa/recovery/`) succeeds; `TwoFactorMiddleware` guards the
session-authenticated surface.
- **Permission enforcement.** `HybridPermission` / `require_permission` are now
wired into security and audit viewsets with documented codenames and a
tenant-owner bypass; `HybridPermission` takes tenant ownership into account.
- **API-key rotation** (`/api/v1/auth/api-keys/<id>/rotate/`) and
**user permission/role endpoints** (`/api/v1/auth/users/<id>/permissions/`,
`/users/<id>/roles/`).
- **Billing webhook processing.** Verified events are applied idempotently to
subscriptions, entitlements, invoices, and `PaymentTransaction` rows; replay
protection via `assert_fresh_webhook`.
- **Scheduled billing lifecycle** (`sync_subscriptions`,
`advance_entitlement_lifecycle`, `expire_entitlements`,
`generate_renewal_invoices`) and **notification retries** + log retention, all
wired into `CELERY_BEAT_SCHEDULE`.
- **Audit update diffs** are captured automatically via a `pre_save` snapshot;
**audit retention purge** task added.
- **Feature rollout** (`rollout_percentage`, `environments`,
`ROLLOUT_HASH_ALGORITHM`) and settings-driven flag registration.
- **Login brute-force guard** (per-credential rate limit + IP blacklist),
**configurable password policy** (`PasswordPolicyValidator`), and a correctly
enforced **ALTCHA** proof-of-work.
- **File hardening:** global upload-size limit, processing-pipeline toggle, and
a pluggable virus scanner (`noop`/`clamav`/custom) with `REQUIRE_VIRUS_SCAN`.
- **Workflow guards:** `MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`,
`ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`.
- **MercadoPago** webhook signature verification.
- `README.md`, `CHANGELOG.md`, and a CI format/coverage gate.
### Changed
- `TenantRateThrottle` and `RateLimitHeadersMiddleware` are active by default,
producing `X-RateLimit-*` headers on API responses.
- `EntitlementService` treats `past_due` as within grace (entitled) and merges
entitlement-level feature overrides over `plan.features`; `require_limit`
raises `ENTITLEMENT_LIMIT_REACHED`.
- `FeatureService` resolves the current tenant automatically and honors rollout
and environment targeting.
### Fixed
- API-key authentication no longer leaks tenant context.
- `EventRegistry.emit` no longer uses `__import__` and honors
`DELIVERY_BACKEND`.
- Test media artifacts no longer accumulate in the repository tree.

440
PLAN.md
View file

@ -11,6 +11,12 @@
InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (`infrasynth-base`), se configura desde `settings.py`, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags. InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (`infrasynth-base`), se configura desde `settings.py`, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags.
**Es el único kit compartido.** Cada app desplegada (Messenger, Invoicer, y las futuras) depende de este paquete y no reimplementa nada de lo que aquí vive. La arquitectura anterior de "cuatro paquetes pequeños" (`infrasynth-auth`, `infrasynth-license-sdk`, `infrasynth-update-client`, `infrasynth-api-conventions`) está retirada: `auth` → `infrasynth.security`, `api-conventions` → la capa API de este kit, y `license-sdk`/`update-client` se eliminan (ver abajo).
**Cada app es multi-tenant.** Un solo despliegue por app sirve a todos los clientes; cada cliente es un **tenant** (workspace). El aislamiento es a nivel de fila con `tenant_id` sobre un único esquema compartido. Ver `../TENANCY.md` (fuente de verdad) — este paquete provee la app `infrasynth.tenancy` que lo implementa.
**No hay servidor de licencias.** Todo corre en nuestra propia infraestructura, así que no hay claves firmadas, ni phone-home, ni SDK offline, ni grace period de validación. Lo que un tenant puede usar es un **entitlement** (derecho comercial) verificado en proceso por `infrasynth.billing`. Ver `../ENTITLEMENTS.md`. El despliegue es por CI/CD propio, sin supervisor ni banner de actualización (`../DEPLOYMENT.md`).
**Principio rector:** Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags. **Principio rector:** Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags.
> **IMPORTANTE:** Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como `✅` (completada), `🔄` (en progreso), o `⬜` (pendiente) con la fecha del cambio. > **IMPORTANTE:** Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como `✅` (completada), `🔄` (en progreso), o `⬜` (pendiente) con la fecha del cambio.
@ -108,7 +114,7 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
- [x] Tests: run_now triggers Celery, toggle enables/disables ✅ _(2026-07-30)_ - [x] Tests: run_now triggers Celery, toggle enables/disables ✅ _(2026-07-30)_
### Fase 13 — Facturación (`infrasynth/billing/`) ✅ _(2026-07-30)_ ### Fase 13 — Facturación (`infrasynth/billing/`) ✅ _(2026-07-30)_
- [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction - [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction _(extendido en Fase 17: App, Plan, Entitlement)_
- [x] `gateways/base.py` — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult - [x] `gateways/base.py` — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult
- [x] Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive - [x] Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive
- [x] `services.py` — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ _(2026-07-30)_ - [x] `services.py` — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ _(2026-07-30)_
@ -133,6 +139,42 @@ InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infra
- [x] Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md - [x] Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md
- [ ] Docker push (pendiente de registry config) - [ ] Docker push (pendiente de registry config)
### Fase 16 — Multi-tenancy ✅ _(2026-09-24)_
- [x] `infrasynth.tenancy` — Tenant, TenantMembership, TenantInvitation, PlatformStaff, TenantManager/AllObjectsManager/GlobalOrTenantManager, TenantMiddleware, `current_tenant` ContextVar, `INFRASYNTH_TENANCY`
- [x] `tenant_id` + `TenantManager` en todos los modelos tenant-owned (audit, security, files, notifications, webhooks, workflows, scheduler, billing) vía `TenantOwnedModel`/`GlobalOrTenantModel`
- [x] Restricciones compuestas `(tenant, …)` e índices que empiezan con `tenant_id`
- [x] Propagación explícita de `tenant_id` a Celery tasks y signals; claves de caché/rate-limit con prefijo `tenant:{id}:`
- [x] `TenantProtocol` real (UUID no-nulo, ya no stub)
- [x] Suite de tests de aislamiento (tenant A no puede leer/escribir/borrar datos de tenant B; acceso cross-tenant → 404)
### Fase 17 — Entitlements y facturación multi-tenant ✅ _(2026-09-24)_
- [x] `billing` — modelos `App`, `Plan` (one_time/subscription), `Entitlement`; `tenant` en Subscription/Invoice/PaymentTransaction; dinero en unidades menores (BigInteger)
- [x] `EntitlementService` (`is_entitled`, `check_limit`) con caché por tenant e invalidación en mutaciones
- [x] Ciclo de vida `past_due` → `grace` → `suspended` a nivel tenant (reinstatement al pagar)
- [x] Códigos de error `ENTITLEMENT_*` en la capa de excepciones (`infrasynth.shared.exceptions`)
- [x] Gate = tenant activo AND entitled AND feature flag (los flags siguen siendo toggles operativos)
### Fase 18 — Capa API (`infrasynth.api`) y estándar ✅ _(2026-09-24)_
- [x] `renderers.py` — EnvelopeJSONRenderer (envelope + camelCase), `meta.requestId/timestamp/tenantId/pagination`
- [x] `pagination.py` — CursorPagination (`pageSize`, `nextCursor`/`prevCursor`)
- [x] `exceptions.py` — envelope_exception_handler + códigos namespaced (`shared.exceptions`)
- [x] `middleware.py` — RequestIdMiddleware + RateLimitHeadersMiddleware
- [x] `idempotency.py` (Idempotency-Key), `throttling.py` (tenant-scoped), `webhooks.py` (replay window), `schema.py` (drf-spectacular)
- [x] Prefijo de versión `/api/v1/`; login multi-workspace (select/switch) con claim `tenant` en el JWT; API keys tenant-scoped
### Fase 19 — Endurecimiento a producción ✅ _(2026-09-24)_
- [x] Webhooks entrantes verificados: HMAC / `BaseInboundHandler.verify`, límite de tamaño, tolerancia de timestamp, idempotencia por `external_id`, handler `process()` ejecutado y `is_verified`/`is_processed` persistidos
- [x] 2FA real en login (pre-auth session + cookie, tokens sólo tras verificar) y `TwoFactorMiddleware` para sesión
- [x] Permisos cableados: `HybridPermission`/`require_permission` en security y audit, bypass de owner del tenant, rotación de API keys, endpoints `users/<id>/permissions` y `/roles`
- [x] Webhooks de pago procesados e idempotentes (suscripción/entitlement/invoice/`PaymentTransaction`), replay protegido, firma MercadoPago
- [x] Ciclo de vida de entitlements programado (sync, past_due→grace→suspended, expiración, facturas de renovación)
- [x] Reintentos de notificaciones + rate limit por canal + retención de logs; captura automática de diffs de update en audit + retención
- [x] Feature rollout (%) y targeting por entorno; registración de flags desde settings
- [x] Login brute-force guard, política de contraseñas, ALTCHA con PoW real; límite global de subida, virus scanner pluggable, toggle de pipelines
- [x] Guardas de workflow (`MAX_INSTANCES_PER_WORKFLOW`, `ROUTE_MAX_DEPTH`, `ALLOW_SELF_ASSIGNMENT`, `AUTO_CLONE_ASSIGNEES_ON_REENTRY`)
- [x] Throttling tenant-scoped por defecto; `CELERY_BEAT_SCHEDULE` con trabajos periódicos
- [x] README + CHANGELOG; CI con `ruff format --check` y umbral de cobertura
## 1. Estructura del Paquete ## 1. Estructura del Paquete
``` ```
@ -164,8 +206,31 @@ backend-package/ # ← repo root / pip package roo
│ │ ├── crypto.py # FernetAES encrypt/decrypt, key rotation │ │ ├── crypto.py # FernetAES encrypt/decrypt, key rotation
│ │ ├── enums.py # Enums base (ChannelType, EventSeverity, BillingInterval, etc.) │ │ ├── enums.py # Enums base (ChannelType, EventSeverity, BillingInterval, etc.)
│ │ ├── results.py # Result[T, E] monad │ │ ├── results.py # Result[T, E] monad
│ │ ├── exceptions.py # Excepciones base zero-Django (AppError, EntitlementError, AuthError, …)
│ │ └── settings_utils.py # get_setting() helper con defaults │ │ └── settings_utils.py # get_setting() helper con defaults
│ │ │ │
│ ├── api/ # Capa API (DRF, no es Django app): implementa API-STANDARD.md
│ │ ├── __init__.py
│ │ ├── renderers.py # EnvelopeJSONRenderer (envelope + camelCase)
│ │ ├── exceptions.py # envelope_exception_handler (mapea shared.exceptions a códigos namespaced)
│ │ ├── pagination.py # CursorPagination
│ │ └── middleware.py # RequestIdMiddleware
│ │
│ ├── tenancy/ # Django app: 'infrasynth.tenancy' — ver ../TENANCY.md
│ │ ├── __init__.py
│ │ ├── apps.py # TenancyConfig, registra flags "tenancy", "tenancy_memberships"
│ │ ├── models.py # Tenant, TenantMembership
│ │ ├── context.py # current_tenant ContextVar + get/set/reset
│ │ ├── managers.py # TenantManager, AllObjectsManager
│ │ ├── middleware.py # TenantMiddleware (resuelve tenant desde el claim del token)
│ │ ├── services.py # TenantService (membership, switch, suspend, offboard)
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── filters.py
│ │ ├── urls.py
│ │ ├── signals.py
│ │ └── migrations/
│ │
│ ├── audit/ # Django app: 'infrasynth.audit' │ ├── audit/ # Django app: 'infrasynth.audit'
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── apps.py # AuditConfig(AppConfig), registra flag "audit" │ │ ├── apps.py # AuditConfig(AppConfig), registra flag "audit"
@ -302,7 +367,7 @@ backend-package/ # ← repo root / pip package roo
│ └── billing/ # Django app: 'infrasynth.billing' │ └── billing/ # Django app: 'infrasynth.billing'
│ ├── __init__.py │ ├── __init__.py
│ ├── apps.py # BillingConfig, registra flag "billing" │ ├── apps.py # BillingConfig, registra flag "billing"
│ ├── models.py # PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction │ ├── models.py # PaymentGateway, App, Plan, Entitlement, Subscription, Invoice, PaymentTransaction
│ ├── services.py # Billing service stub │ ├── services.py # Billing service stub
│ ├── invoice_generator.py # Generación de PDF (Celery task stub) │ ├── invoice_generator.py # Generación de PDF (Celery task stub)
│ ├── gateways/ │ ├── gateways/
@ -335,6 +400,58 @@ backend-package/ # ← repo root / pip package roo
## 2. Especificación Detallada por App ## 2. Especificación Detallada por App
### 2.0 Contrato de Multi-tenancy (aplica a TODAS las apps)
Toda app es multi-tenant. La fuente de verdad es `../TENANCY.md`; esta sección solo resume el contrato que los modelos de abajo cumplen.
**Regla de oro:**
> Todo modelo **tenant-owned** tiene un FK no-nulo `tenant`, un manager por defecto `TenantManager` y un escape hatch `all_objects`. Toda query de datos de tenant pasa por el manager scopeado. No hay excepción, y "me acordaré de filtrar" no es un diseño.
```python
# Patrón que TODO modelo tenant-owned sigue (se omite en los bloques de abajo por brevedad,
# salvo donde el scoping no es obvio):
class CualquierModeloDeTenant(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
# ...
objects = TenantManager() # por defecto — SIEMPRE scopeado al tenant actual
all_objects = AllObjectsManager() # sin scope — solo migraciones, admin y platform staff
```
- **Fail closed:** sin contexto de tenant, el manager scopeado devuelve queryset vacío. Un query que "funciona" sin tenant es un bug.
- **Acceso cross-tenant → `404`, nunca `403`** (un `403` confirma que el objeto existe: fuga de información).
- **Unicidad por tenant:** todo lo que era único global pasa a `unique_together = (tenant, campo)` (o `UniqueConstraint` con `tenant` primero). Índices empiezan con `tenant_id`.
- **FK cross-tenant prohibido:** una fila de tenant solo referencia filas globales o de su mismo tenant.
- **Tareas Celery, signals y claves de caché** llevan `tenant_id` explícito; las claves se prefijan `tenant:{id}:` (`../TENANCY.md` §7).
- **`unsafe_all()` nunca se llama desde una vista.**
**Scoping de cada modelo del kit:**
| App | Modelo | Scoping |
|---|---|---|
| `shared` | — | Zero-Django, no aplica |
| `api` | — | Capa DRF, no tiene modelos |
| `tenancy` | `Tenant`, `TenantMembership` | Definen el scoping (no se auto-scopean) |
| `audit` | `ModelChangeLog`, `APIInteractionLog`, `SecurityEvent` | Tenant-owned (`tenant_id` nulo solo para acciones de plataforma) |
| `security` | `Role` | Global (`tenant_id = NULL` = rol de sistema) + override por tenant |
| `security` | `Grant`, `Revoke`, `APIKey` | Tenant-owned |
| `security` | `TwoFactorConfig` | Global por usuario (el usuario es global) |
| `security` | `ALTCHAChallenge` | Global (efímero, anti-spam) |
| `files` | `StoredFile`, `FileCategory`, `PipelineExecution` | Tenant-owned |
| `files` | `ProcessingPipeline` | Global + override por tenant |
| `notifications` | `NotificationTemplate` | Global + override por tenant |
| `notifications` | `NotificationDispatch`, `ChannelConfig` | Tenant-owned |
| `webhooks` | `OutboundEndpoint`, `OutboundSubscription`, `OutboundDelivery`, `InboundEndpoint`, `InboundEvent` | Tenant-owned (`InboundEndpoint` resuelve el tenant por slug + secreto) |
| `workflows` | `Workflow`, `WorkflowNode`, `Transition`, `WorkflowInstance`, `NodeAssignment`, `WorkflowObserver` | Tenant-owned |
| `scheduler` | `ScheduledTask`, `TaskExecution` | Tenant-owned |
| `features` | `FeatureFlag` | Global (`tenant_id = NULL`) + override por tenant |
| `features` | `FeatureFlagOverride` | Tenant-owned |
| `billing` | `PaymentGateway` | Global (cuentas de la plataforma) |
| `billing` | `App`, `Plan` | Global (catálogo) |
| `billing` | `Entitlement`, `Subscription`, `Invoice`, `PaymentTransaction` | Tenant-owned |
**Usuarios e identidad:** el `User` es global (email único dentro de la app); la pertenencia a tenants es vía `TenantMembership` (un usuario puede pertenecer a varios tenants, con rol distinto en cada uno). Nunca un FK `tenant` en el modelo de usuario.
### 2.1 `infrasynth.shared` — Fundación Cero-Django ### 2.1 `infrasynth.shared` — Fundación Cero-Django
**Propósito:** Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema. **Propósito:** Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema.
@ -374,8 +491,8 @@ class EventProtocol(Protocol):
timestamp: str timestamp: str
class TenantProtocol(Protocol): class TenantProtocol(Protocol):
"""Protocolo para modelos que soporten multi-tenant.""" """Contrato de todo modelo tenant-owned. tenant_id es no-nulo en filas de tenant."""
tenant_id: str | None tenant_id: UUID
``` ```
#### `crypto.py` — Utilidades Criptográficas #### `crypto.py` — Utilidades Criptográficas
@ -904,7 +1021,6 @@ grant_revoked = Signal() # kwargs: user, codename, reason
| `/security/revokes/<id>/` | DELETE | `security.manage_grants` | Elimina revoke | | `/security/revokes/<id>/` | DELETE | `security.manage_grants` | Elimina revoke |
| `/security/users/<id>/permissions/` | GET | `security.view_permissions` | Permisos efectivos del usuario | | `/security/users/<id>/permissions/` | GET | `security.view_permissions` | Permisos efectivos del usuario |
| `/security/users/<id>/roles/` | GET, PUT | `security.manage_roles` | Roles del usuario | | `/security/users/<id>/roles/` | GET, PUT | `security.manage_roles` | Roles del usuario |
#### Configuración Externalizable #### Configuración Externalizable
```python ```python
@ -2452,16 +2568,85 @@ INFRASYNTH_FEATURES = {
--- ---
### 2.10 `infrasynth.billing` — Pagos y Suscripciones ### 2.10 `infrasynth.billing` — Pagos, Planes y Entitlements
**Feature flag:** `billing` (default: **False** — requiere activación explícita) **Feature flag:** `billing` (default: **False** — requiere activación explícita)
**Dependencias:** `infrasynth.shared`, `infrasynth.audit` **Dependencias:** `infrasynth.shared`, `infrasynth.audit`, `infrasynth.tenancy`
> Este app es la fuente de enforcement comercial. **No hay servidor de licencias ni claves firmadas.** Lo que un tenant puede usar es un `Entitlement`, verificado en proceso por `EntitlementService`. Ver `../ENTITLEMENTS.md`.
#### Modelos #### Modelos
```python
class App(models.Model):
"""Una app desplegada en el catálogo (global)."""
slug = models.SlugField(max_length=100, unique=True) # "messenger", "invoicer"
name = models.CharField(max_length=200)
monetization = models.CharField( # default a nivel de app
max_length=20,
choices=[("one_time","one_time"),("subscription","subscription")],
)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = "billing_app"
class Plan(models.Model):
"""Tier comprable de una app (global)."""
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans")
slug = models.SlugField(max_length=100)
name = models.CharField(max_length=200)
price_amount = models.BigIntegerField() # UNIDADES MENORES (centavos) — nunca float
price_currency = models.CharField(max_length=3, default="USD") # ISO 4217
interval = models.CharField( # one_time | monthly | yearly
max_length=20,
choices=[("one_time","one_time"),("monthly","monthly"),("yearly","yearly")],
default="monthly",
)
trial_days = models.PositiveIntegerField(default=0)
features = models.JSONField(default=dict) # {"broadcast": true, "analytics": false}
limits = models.JSONField(default=dict) # {"max_agents": 10}
is_active = models.BooleanField(default=True)
gateway = models.ForeignKey("PaymentGateway", on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True)
class Meta:
db_table = "billing_plan"
unique_together = [("app", "slug")]
class Entitlement(models.Model):
"""Derecho de un tenant a usar una app bajo un plan. Tenant-owned. Fuente única de enforcement."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="entitlements")
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements")
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
status = models.CharField(
max_length=20,
choices=[("trialing","trialing"),("active","active"),("past_due","past_due"),
("grace","grace"),("suspended","suspended"),("expired","expired"),
("cancelled","cancelled"),("revoked","revoked")],
default="active",
)
started_at = models.DateTimeField(auto_now_add=True)
current_period_end = models.DateTimeField(null=True, blank=True) # suscripciones
expires_at = models.DateTimeField(null=True, blank=True) # NULL = one_time / perpetuo
cancel_at_period_end = models.BooleanField(default=False)
source = models.CharField(max_length=20, default="manual") # manual | stripe | mercadopago | wompi
metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
db_table = "billing_entitlement"
constraints = [models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement")]
```
```python ```python
class PaymentGateway(models.Model): class PaymentGateway(models.Model):
"""Configuración de una pasarela de pago.""" """Configuración de una pasarela de pago (global — cuenta de la plataforma)."""
slug = models.SlugField(max_length=50, primary_key=True) slug = models.SlugField(max_length=50, primary_key=True)
display_name = models.CharField(max_length=200) display_name = models.CharField(max_length=200)
gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway") gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway")
@ -2472,30 +2657,14 @@ class PaymentGateway(models.Model):
class Meta: class Meta:
db_table = "billing_gateway" db_table = "billing_gateway"
```
```python
class BillingPlan(models.Model):
"""Plan de suscripción/pago."""
slug = models.SlugField(max_length=100, unique=True)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
price_amount = models.DecimalField(max_digits=12, decimal_places=2) # En centavos/subunidad
price_currency = models.CharField(max_length=3, default="USD")
interval = models.CharField(max_length=20, choices=[("monthly","Monthly"),("yearly","Yearly")], default="monthly")
trial_days = models.PositiveIntegerField(default=0)
features = models.JSONField(default=list, help_text='["10,000 emails/mes", "Soporte prioritario"]')
is_active = models.BooleanField(default=True)
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True, help_text="ID del plan en la pasarela (ej. Stripe price ID)")
class Meta:
db_table = "billing_plan"
class Subscription(models.Model): class Subscription(models.Model):
"""Suscripción activa de un usuario.""" """Suscripción activa de un tenant (tenant-owned)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="subscriptions") tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="subscriptions")
plan = models.ForeignKey(BillingPlan, on_delete=models.SET_NULL, null=True) entitlement = models.ForeignKey(Entitlement, on_delete=models.SET_NULL, null=True, related_name="subscriptions")
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True)
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True) external_id = models.CharField(max_length=200, blank=True)
status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus]) status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus])
@ -2506,20 +2675,23 @@ class Subscription(models.Model):
trial_end = models.DateTimeField(null=True) trial_end = models.DateTimeField(null=True)
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta: class Meta:
db_table = "billing_subscription" db_table = "billing_subscription"
class Invoice(models.Model): class Invoice(models.Model):
"""Factura generada.""" """Factura generada (tenant-owned)."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="invoices")
subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices") subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices")
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True) external_id = models.CharField(max_length=200, blank=True)
invoice_number = models.CharField(max_length=50, unique=True) invoice_number = models.CharField(max_length=50, unique=True)
amount = models.DecimalField(max_digits=12, decimal_places=2) amount = models.BigIntegerField() # UNIDADES MENORES
currency = models.CharField(max_length=3, default="USD") currency = models.CharField(max_length=3, default="USD")
tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) tax_amount = models.BigIntegerField(default=0)
tax_name = models.CharField(max_length=50, blank=True, default="") tax_name = models.CharField(max_length=50, blank=True, default="")
status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft") status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft")
due_date = models.DateTimeField(null=True) due_date = models.DateTimeField(null=True)
@ -2528,22 +2700,29 @@ class Invoice(models.Model):
pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+") pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+")
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta: class Meta:
db_table = "billing_invoice" db_table = "billing_invoice"
class PaymentTransaction(models.Model): class PaymentTransaction(models.Model):
"""Transacción de pago individual.""" """Transacción de pago individual (tenant-owned)."""
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="payment_transactions")
invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions") invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions")
gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True)
external_id = models.CharField(max_length=200, blank=True) external_id = models.CharField(max_length=200, blank=True)
amount = models.DecimalField(max_digits=12, decimal_places=2) amount = models.BigIntegerField() # UNIDADES MENORES
currency = models.CharField(max_length=3, default="USD") currency = models.CharField(max_length=3, default="USD")
status = models.CharField(max_length=30) status = models.CharField(max_length=30)
payment_method = models.CharField(max_length=100, blank=True) payment_method = models.CharField(max_length=100, blank=True)
metadata = models.JSONField(default=dict) metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta: class Meta:
db_table = "billing_transaction" db_table = "billing_transaction"
``` ```
@ -2626,7 +2805,10 @@ invoice_paid = Signal() # kwargs: user, invoice_id, amount
| `/billing/gateways/` | GET | IsAuthenticated | Pasarelas activas | | `/billing/gateways/` | GET | IsAuthenticated | Pasarelas activas |
| `/billing/plans/` | GET | None | Planes disponibles | | `/billing/plans/` | GET | None | Planes disponibles |
| `/billing/plans/<slug>/` | GET | None | Detalle plan | | `/billing/plans/<slug>/` | GET | None | Detalle plan |
| `/billing/subscriptions/` | GET | IsAuthenticated | Suscripciones del usuario | | `/billing/entitlements/` | GET | IsAuthenticated | Entitlements del tenant actual (todas las apps) |
| `/billing/entitlements/<app_slug>/` | GET | IsAuthenticated | Entitlement del tenant para una app |
| `/billing/checkout/` | POST | IsAuthenticated | Crear checkout `{app, plan}` (tenant del token) |
| `/billing/subscriptions/` | GET | IsAuthenticated | Suscripciones del tenant actual |
| `/billing/subscriptions/<id>/` | GET | IsAuthenticated | Detalle suscripción | | `/billing/subscriptions/<id>/` | GET | IsAuthenticated | Detalle suscripción |
| `/billing/subscriptions/<id>/cancel/` | POST | IsAuthenticated | Cancelar suscripción | | `/billing/subscriptions/<id>/cancel/` | POST | IsAuthenticated | Cancelar suscripción |
| `/billing/subscribe/<plan_slug>/` | POST | IsAuthenticated | Crear checkout (retorna redirect URL) | | `/billing/subscribe/<plan_slug>/` | POST | IsAuthenticated | Crear checkout (retorna redirect URL) |
@ -2654,6 +2836,143 @@ INFRASYNTH_BILLING = {
--- ---
### 2.11 `infrasynth.tenancy` — Tenants, Membresía y Contexto
**Feature flag:** `tenancy` (default: True — es core)
**Dependencias:** `infrasynth.shared`, `infrasynth.audit`
**Propósito:** proveer el modelo de tenant, la membresía usuario↔tenant, el contexto de request y los managers scopeados que hacen cumplir el aislamiento. Es la implementación de `../TENANCY.md`.
#### Modelos
```python
class Tenant(models.Model):
"""Una empresa cliente. PK UUID para exponerla con seguridad."""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
slug = models.SlugField(max_length=100, unique=True) # handle público, ej. "acme"
name = models.CharField(max_length=200)
status = models.CharField(
max_length=20,
choices=[("trialing","trialing"),("active","active"),
("suspended","suspended"),("archived","archived")],
default="active",
)
locale = models.CharField(max_length=10, default="es")
timezone = models.CharField(max_length=64, default="UTC")
metadata = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
suspended_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "tenancy_tenant"
class TenantMembership(models.Model):
"""Pertenencia de un usuario a un tenant. Única forma correcta de ligar usuario y tenant."""
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="memberships")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="tenant_memberships")
role = models.CharField(max_length=50, default="member") # slug de rol dentro del tenant
is_owner = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "tenancy_membership"
unique_together = [("tenant", "user")]
indexes = [models.Index(fields=["user", "is_active"])]
```
#### Contexto y Managers
```python
# infrasynth/tenancy/context.py
from contextvars import ContextVar
current_tenant: ContextVar[Tenant | None] = ContextVar("current_tenant", default=None)
# infrasynth/tenancy/managers.py
class TenantManager(models.Manager):
"""Manager por defecto de todo modelo tenant-owned. Scopea al tenant actual."""
def get_queryset(self):
tenant = current_tenant.get()
if tenant is None:
return super().get_queryset().none() # fail closed
return super().get_queryset().filter(tenant_id=tenant.id)
def unsafe_all(self):
"""Escape hatch explícito para código de sistema. Nunca desde una vista."""
return super().get_queryset()
class AllObjectsManager(models.Manager):
"""Manager sin scope (`all_objects`) para migraciones, admin y platform staff."""
```
#### Middleware
`TenantMiddleware` corre **después** de la autenticación. Lee el claim `tenant` del token, verifica que la membresía siga activa (si no, `403 AUTH_MEMBERSHIP_REVOKED` — nunca espera a que expire el token), setea `current_tenant` y limpia el contexto al terminar. Rechaza requests a endpoints de tenant cuando no hay tenant resuelto, salvo el allowlist (login, select/switch-workspace, health, webhooks, catálogo).
#### `TenantService`
```python
class TenantService:
def get_active_memberships(self, user) -> list[TenantMembership]: ...
def select_tenant(self, user, tenant_id) -> Tenant: ... # valida membresía activa
def switch_tenant(self, user, tenant_id) -> tuple[str, str]: ... # (access, refresh) nuevos
def create_tenant(self, name, owner, slug=None) -> Tenant: ... # crea tenant + membership owner
def invite(self, tenant, email, role) -> Invitation: ...
def suspend(self, tenant, reason) -> None: ...
def reinstate(self, tenant) -> None: ...
def offboard(self, tenant) -> None: ... # export → archive → delete diferido
```
#### API Endpoints
| Endpoint | Método | Permiso | Descripción |
|---|---|---|---|
| `/auth/select-workspace/` | POST | None (pre-auth) | Elegir workspace tras login multi-workspace. Emite tokens con claim `tenant` |
| `/auth/switch-workspace/` | POST | IsAuthenticated | Cambiar de workspace (rota refresh token). Auditado |
| `/tenancy/tenants/` | GET, POST | IsAuthenticated | Listar/crear tenants del usuario |
| `/tenancy/tenants/<id>/` | GET, PUT | `tenancy.manage_tenant` | Detalle/edición del tenant |
| `/tenancy/tenants/<id>/members/` | GET, POST | `tenancy.manage_members` | Listar/invitar miembros |
| `/tenancy/tenants/<id>/members/<id>/` | DELETE | `tenancy.manage_members` | Revocar membresía (invalida sesión) |
#### Configuración Externalizable
```python
INFRASYNTH_TENANCY = {
"TENANT_MODEL": "infrasynth.tenancy.Tenant",
"MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
"TENANT_CLAIM": "tenant", # nombre del claim en el JWT
"REQUIRE_TENANT_BY_DEFAULT": True, # endpoints sin tenant → 403 salvo allowlist
"TENANT_ALLOWLIST_PATHS": ["/api/v1/auth/", "/api/v1/billing/webhook/", "/healthz", "/readyz"],
"ENABLE_WORKSPACE_SWITCHING": True,
"DEFAULT_LOCALE": "es",
"DEFAULT_TIMEZONE": "UTC",
}
```
#### Patrón de Integración para App B
```python
# App B: helpdesk/models.py — un modelo tenant-owned
from infrasynth.tenancy.managers import TenantManager, AllObjectsManager
class Ticket(models.Model):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
subject = models.CharField(max_length=255)
objects = TenantManager()
all_objects = AllObjectsManager()
class Meta:
constraints = [models.UniqueConstraint(fields=["tenant", "slug"], name="uniq_ticket_slug_per_tenant")]
# App B: cualquier vista — el manager ya scopea; no se filtra a mano
Ticket.objects.all() # solo tickets del tenant actual
Ticket.all_objects.all() # TODOS los tenants — solo para admin/management
```
---
## 3. Patrones de Acoplamiento ## 3. Patrones de Acoplamiento
### 3.1 Regla de Oro ### 3.1 Regla de Oro
@ -2721,6 +3040,7 @@ infrasynth.shared
↑ ↑
├── infrasynth.audit ├── infrasynth.audit
│ ↑ │ ↑
│ ├── infrasynth.tenancy ← define el aislamiento; todas las apps lo usan
│ ├── infrasynth.security │ ├── infrasynth.security
│ ├── infrasynth.files │ ├── infrasynth.files
│ ├── infrasynth.notifications │ ├── infrasynth.notifications
@ -2728,11 +3048,12 @@ infrasynth.shared
│ ├── infrasynth.workflows │ ├── infrasynth.workflows
│ ├── infrasynth.scheduler │ ├── infrasynth.scheduler
│ ├── infrasynth.features │ ├── infrasynth.features
│ └── infrasynth.billing │ └── infrasynth.billing ← usa tenancy (los entitlements son tenant-owned)
│ │
│ (Todas las apps de infraestructura dependen solo de shared + audit) │ (Todas las apps de infraestructura dependen solo de shared + audit;
│ las apps tenant-owned usan infrasynth.tenancy para managers y contexto)
│ │
└── infrasynth.features ← es el orquestador transversal └── infrasynth.features ← es el orquestador transversal de flags operativos
↑ ↑
(Todas las apps registran sus flags aquí, pero NO importan features) (Todas las apps registran sus flags aquí, pero NO importan features)
``` ```
@ -2909,6 +3230,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
# InfraSynth Base (TODAS las apps, features controla visibilidad) # InfraSynth Base (TODAS las apps, features controla visibilidad)
"infrasynth.tenancy",
"infrasynth.audit", "infrasynth.audit",
"infrasynth.security", "infrasynth.security",
"infrasynth.files", "infrasynth.files",
@ -2932,6 +3254,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware",
"infrasynth.tenancy.middleware.TenantMiddleware",
"infrasynth.security.two_factor.middleware.TwoFactorMiddleware", "infrasynth.security.two_factor.middleware.TwoFactorMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
@ -2946,7 +3269,9 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
], ],
"DEFAULT_PAGINATION_CLASS": "infrasynth.shared.pagination.StandardPagination", "DEFAULT_RENDERER_CLASSES": ["infrasynth.api.renderers.EnvelopeJSONRenderer"],
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
"DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination",
"PAGE_SIZE": 25, "PAGE_SIZE": 25,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
} }
@ -2963,6 +3288,15 @@ INFRASYNTH_SECURITY = {
"TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro", "TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro",
} }
INFRASYNTH_TENANCY = {
"TENANT_MODEL": "infrasynth.tenancy.Tenant",
"MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
"TENANT_CLAIM": "tenant",
"REQUIRE_TENANT_BY_DEFAULT": True,
"DEFAULT_LOCALE": "es",
"DEFAULT_TIMEZONE": "UTC",
}
INFRASYNTH_FILES = { INFRASYNTH_FILES = {
"DEFAULT_STORAGE_BACKEND": "S3", "DEFAULT_STORAGE_BACKEND": "S3",
"STORAGE_BACKENDS": { "STORAGE_BACKENDS": {
@ -3063,6 +3397,7 @@ from infrasynth.workflows.models import WorkflowAwareModel
from infrasynth.files.models import StoredFile from infrasynth.files.models import StoredFile
class Ticket(WorkflowAwareModel): class Ticket(WorkflowAwareModel):
tenant = models.ForeignKey("tenancy.Tenant", on_delete=models.CASCADE, related_name="+")
subject = models.CharField(max_length=255) subject = models.CharField(max_length=255)
description = models.TextField() description = models.TextField()
priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")]) priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")])
@ -3072,6 +3407,9 @@ class Ticket(WorkflowAwareModel):
attachments = models.ManyToManyField(StoredFile, blank=True, related_name="+") attachments = models.ManyToManyField(StoredFile, blank=True, related_name="+")
resolution = models.TextField(blank=True) resolution = models.TextField(blank=True)
objects = TenantManager() # queries scopeadas al tenant actual
all_objects = AllObjectsManager() # solo admin/management
# ============================================================ # ============================================================
# helpdesk/views.py # helpdesk/views.py
# ============================================================ # ============================================================
@ -3082,6 +3420,7 @@ class TicketViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")] permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")]
def get_queryset(self): def get_queryset(self):
# Ticket.objects ya está scopeado al tenant actual por TenantManager (fail closed).
qs = Ticket.objects.select_related("assigned_to", "created_by") qs = Ticket.objects.select_related("assigned_to", "created_by")
authz = AuthorizationService() authz = AuthorizationService()
if not authz.has_permission(self.request.user, "helpdesk.view_all_tickets"): if not authz.has_permission(self.request.user, "helpdesk.view_all_tickets"):
@ -3089,18 +3428,24 @@ class TicketViewSet(ModelViewSet):
return qs return qs
def perform_create(self, serializer): def perform_create(self, serializer):
ticket = serializer.save(created_by=self.request.user) tenant = current_tenant.get()
# Gate = tenant activo AND entitled AND feature flag operativo
if not EntitlementService().is_entitled(tenant, "helpdesk", feature="tickets"):
raise EntitlementError(code="ENTITLEMENT_PLAN_UPGRADE_REQUIRED", app="helpdesk", feature="tickets")
# Disparar evento → webhooks outbound reaccionan ticket = serializer.save(created_by=self.request.user, tenant=tenant)
# Disparar evento → webhooks outbound reaccionan (tenant_id viaja explícito)
from infrasynth.webhooks.registry import EventRegistry from infrasynth.webhooks.registry import EventRegistry
EventRegistry.emit("helpdesk.ticket.created", { EventRegistry.emit("helpdesk.ticket.created", {
"tenant_id": str(tenant.id),
"ticket_id": ticket.id, "ticket_id": ticket.id,
"subject": ticket.subject, "subject": ticket.subject,
"priority": ticket.priority, "priority": ticket.priority,
}) })
# Auto-assign si el feature flag está activo # Auto-assign si el feature flag operativo está activo para este tenant
if FeatureService().is_enabled("helpdesk.auto_assign"): if FeatureService().is_enabled("helpdesk.auto_assign", tenant_id=tenant.id):
assign_ticket_to_best_agent(ticket) assign_ticket_to_best_agent(ticket)
``` ```
@ -3119,7 +3464,8 @@ class TicketViewSet(ModelViewSet):
| `infrasynth/webhooks/` | Django app: inbound/outbound con HMAC, EventRegistry | | `infrasynth/webhooks/` | Django app: inbound/outbound con HMAC, EventRegistry |
| `infrasynth/workflows/` | Django app: máquina de estados con votación, WorkflowAwareModel mixin | | `infrasynth/workflows/` | Django app: máquina de estados con votación, WorkflowAwareModel mixin |
| `infrasynth/scheduler/` | Django app: dashboard y API de jobs Celery | | `infrasynth/scheduler/` | Django app: dashboard y API de jobs Celery |
| `infrasynth/tenancy/` | Django app: Tenant, TenantMembership, managers scopeados, middleware, contexto de request |
| `infrasynth/features/` | Django app: feature flags con tenant/user overrides, endpoint central `/api/features/active/` | | `infrasynth/features/` | Django app: feature flags con tenant/user overrides, endpoint central `/api/features/active/` |
| `infrasynth/billing/` | Django app: suscripciones, facturas, Stripe/MercadoPago/Wompi | | `infrasynth/billing/` | Django app: App, Plan, Entitlements, suscripciones, facturas, Stripe/MercadoPago/Wompi |
| `tests/` | Test suite completa con pytest + factory_boy | | `tests/` | Test suite completa con pytest + factory_boy, incluye aislamiento de tenants |
| `AGENTS.md` | Guía completa para agentes de IA | | `AGENTS.md` | Guía completa para agentes de IA |

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# InfraSynth Base
Reusable, multi-tenant Django infrastructure kit — the one shared package every InfraSynth app depends on.
```bash
pip install -e ".[dev]" # install the kit + dev tooling
docker compose up -d db redis # postgres + redis
python manage.py migrate # create the schema
python manage.py runserver # http://localhost:8000/api/v1/schema/docs/
```
Then `pytest` (single-command test suite), `ruff check .`, and `mypy infrasynth/`.
---
## What this is
One pip package (`infrasynth-base`) providing ten Django apps so no app ever reimplements auth, tenancy, entitlements, audit, files, notifications, webhooks, workflows, scheduling, or the API envelope:
| Module | Responsibility |
|---|---|
| `infrasynth.shared` | Zero-Django primitives: protocols, enums, `Result`, Fernet crypto, settings helper |
| `infrasynth.api` | DRF envelope, camelCase, cursor pagination, request-id, exceptions, throttling, idempotency, webhook hardening |
| `infrasynth.tenancy` | `Tenant`, membership, invitations, platform staff, `current_tenant`, scoped managers, middleware |
| `infrasynth.security` | JWT cookie + API-key auth, roles/grants/revokes, 2FA (TOTP), ALTCHA, login brute-force guard, password policy |
| `infrasynth.audit` | Passive create/update/delete tracking, API interaction log, security events, retention purge |
| `infrasynth.features` | Operational feature flags with tenant/user/group overrides, rollout %, environment targeting |
| `infrasynth.billing` | App catalog, plans, entitlements, subscriptions, invoices, gateways, entitlement lifecycle jobs |
| `infrasynth.files` | Storage abstraction (S3/GCS/local/Cloudinary), signed URLs, processing pipelines, pluggable virus scanning |
| `infrasynth.notifications` | Multi-channel delivery with failover, retries, rate limits, and log retention |
| `infrasynth.webhooks` | Outbound delivery with HMAC + retry, verified inbound processing, event registry |
| `infrasynth.workflows` | State-machine engine, voting/approval strategies, validators |
| `infrasynth.scheduler` | Celery job dashboard + on-demand execution |
**Every app is multi-tenant.** One deployment, one schema, row-level isolation via a non-null `tenant_id`, a fail-closed `TenantManager`, and cross-tenant access that returns `404`. See `../TENANCY.md` (binding).
**There is no license server.** Access is an in-process *entitlement* (`is_entitled` / `check_limit`), enforced server-side. See `../ENTITLEMENTS.md` (binding).
---
## Configuration
Every knob is a namespaced dict with a safe default, read through `infrasynth.shared.settings_utils.get_setting`:
```python
INFRASYNTH_SECURITY = {"COOKIE_SECURE": True, "IP_BLACKLIST_THRESHOLD": 100}
INFRASYNTH_TENANCY = {"REQUIRE_TENANT_BY_DEFAULT": True, "TENANT_CLAIM": "tenant"}
INFRASYNTH_BILLING = {"GRACE_PERIOD_DAYS": 5, "DEFAULT_CURRENCY": "USD"}
INFRASYNTH_NOTIFICATIONS = {"CHANNELS": {"email": {"primary": "myapp.channels.SlackChannel"}}}
```
The full, commented reference lives in `config/settings/base.py`. Real settings files **never** live in this package — apps supply their own and pin the kit.
---
## Extending without forking
Integrate through settings, signals, registries, ABCs, and feature flags — never a local patch:
```python
# myapp/channels.py
from infrasynth.notifications.channels.base import BaseChannel
from infrasynth.shared.results import Result
class SlackChannel(BaseChannel):
channel_type = "slack"
def send(self, recipient, subject, body, is_html=True, attachments=None):
...
return Result.ok(True)
def health_check(self) -> bool:
return True
@classmethod
def from_config(cls, config): return cls(**config)
```
Registries are populated in `apps.py:ready()`: `FeatureRegistry`, `EventRegistry`, `VariableResolverRegistry`, `DataValidatorRegistry`.
---
## Scheduled work
`CELERY_BEAT_SCHEDULE` ships with the kit: notification retries/log purge, billing sync + lifecycle + renewal invoices, and audit retention. Run `celery -A config beat` and `celery -A config worker`.
---
## Quality bar
- `ruff check` + `ruff format --check` + `mypy infrasynth/` are CI gates.
- `pytest` runs the full suite; coverage is enforced in CI (see `pyproject.toml`).
- A change to kit behavior belongs **here**, then consuming apps bump their pin — see `../AGENTS.backend-packages.md` §9.

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 646
>>
stream
Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<87ef345d02f27d9ba4d6b0fa590299a4><87ef345d02f27d9ba4d6b0fa590299a4>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1657
%%EOF

View file

@ -1,9 +1,12 @@
import os import os
from celery import Celery from celery import Celery
from django.conf import settings
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
app = Celery("config") app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY") app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
if getattr(settings, "INFRASYNTH_SCHEDULER", {}).get("AUTO_DISCOVER_TASKS", True):
app.autodiscover_tasks()

View file

@ -1,4 +1,6 @@
from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import cast
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
@ -18,6 +20,8 @@ INSTALLED_APPS = [
"corsheaders", "corsheaders",
"django_celery_results", "django_celery_results",
"django_celery_beat", "django_celery_beat",
"rest_framework_simplejwt.token_blacklist",
"infrasynth.tenancy",
"infrasynth.audit", "infrasynth.audit",
"infrasynth.security", "infrasynth.security",
"infrasynth.files", "infrasynth.files",
@ -30,6 +34,7 @@ INSTALLED_APPS = [
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"infrasynth.api.middleware.RequestIdMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", "corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
@ -37,10 +42,12 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware",
"infrasynth.tenancy.middleware.TenantMiddleware",
"infrasynth.security.two_factor.middleware.TwoFactorMiddleware", "infrasynth.security.two_factor.middleware.TwoFactorMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"infrasynth.audit.middleware.AuditAPIMiddleware", "infrasynth.audit.middleware.AuditAPIMiddleware",
"infrasynth.api.middleware.RateLimitHeadersMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
@ -68,9 +75,11 @@ TIME_ZONE = "America/Bogota"
USE_TZ = True USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MIGRATION_MODULES = { MIGRATION_MODULES = {
"tenancy": "infrasynth.tenancy.migrations",
"infrasynth_audit": "infrasynth.audit.migrations", "infrasynth_audit": "infrasynth.audit.migrations",
"infrasynth_security": "infrasynth.security.migrations", "infrasynth_security": "infrasynth.security.migrations",
"infrasynth_files": "infrasynth.files.migrations", "infrasynth_files": "infrasynth.files.migrations",
@ -101,9 +110,23 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated", "rest_framework.permissions.IsAuthenticated",
], ],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "DEFAULT_RENDERER_CLASSES": [
"infrasynth.api.renderers.EnvelopeJSONRenderer",
],
"EXCEPTION_HANDLER": "infrasynth.api.exceptions.envelope_exception_handler",
"DEFAULT_PAGINATION_CLASS": "infrasynth.api.pagination.CursorPagination",
"DEFAULT_THROTTLE_CLASSES": ["infrasynth.api.throttling.TenantRateThrottle"],
"PAGE_SIZE": 25, "PAGE_SIZE": 25,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_THROTTLE_RATES": {"tenant": "1000/hour"},
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "InfraSynth Base API",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
} }
CELERY_BROKER_URL = "redis://localhost:6379/0" CELERY_BROKER_URL = "redis://localhost:6379/0"
@ -167,6 +190,7 @@ INFRASYNTH_SECURITY = {
"PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5, "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5,
"ALTCHA_DIFFICULTY": 10000, "ALTCHA_DIFFICULTY": 10000,
"ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300,
"ALTCHA_PROTECT_LOGIN": False,
"API_KEY_PREFIX_LENGTH": 8, "API_KEY_PREFIX_LENGTH": 8,
"API_KEY_HASH_ALGORITHM": "pbkdf2_sha256", "API_KEY_HASH_ALGORITHM": "pbkdf2_sha256",
"API_KEY_DEFAULT_EXPIRY_DAYS": 365, "API_KEY_DEFAULT_EXPIRY_DAYS": 365,
@ -174,12 +198,26 @@ INFRASYNTH_SECURITY = {
"PASSWORD_REQUIRE_UPPERCASE": True, "PASSWORD_REQUIRE_UPPERCASE": True,
"PASSWORD_REQUIRE_DIGIT": True, "PASSWORD_REQUIRE_DIGIT": True,
"PASSWORD_REQUIRE_SPECIAL_CHAR": True, "PASSWORD_REQUIRE_SPECIAL_CHAR": True,
"ENABLE_WORKSPACE_SWITCHING": True,
} }
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "infrasynth.security.password_validation.PasswordPolicyValidator"},
]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
INFRASYNTH_SECURITY["AUTH_BACKEND_CLASS"], INFRASYNTH_SECURITY["AUTH_BACKEND_CLASS"],
] ]
# JWT lifetimes/rotation are derived from the INFRASYNTH_SECURITY block so there
# is a single source of truth.
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=cast(int, INFRASYNTH_SECURITY["ACCESS_TOKEN_LIFETIME_MINUTES"])),
"REFRESH_TOKEN_LIFETIME": timedelta(days=cast(int, INFRASYNTH_SECURITY["REFRESH_TOKEN_LIFETIME_DAYS"])),
"ROTATE_REFRESH_TOKENS": cast(bool, INFRASYNTH_SECURITY["ROTATE_REFRESH_TOKENS"]),
"BLACKLIST_AFTER_ROTATION": cast(bool, INFRASYNTH_SECURITY["BLACKLIST_AFTER_ROTATION"]),
}
INFRASYNTH_FILES = { INFRASYNTH_FILES = {
"DEFAULT_STORAGE_BACKEND": "local", "DEFAULT_STORAGE_BACKEND": "local",
"STORAGE_BACKENDS": { "STORAGE_BACKENDS": {
@ -207,11 +245,12 @@ INFRASYNTH_FILES = {
"ENABLE_PROCESSING_PIPELINES": True, "ENABLE_PROCESSING_PIPELINES": True,
"PROCESSING_BACKEND": "celery", "PROCESSING_BACKEND": "celery",
"ENABLE_X_SENDFILE": False, "ENABLE_X_SENDFILE": False,
"VIRUS_SCANNER": "noop",
"CLAMAV_SOCKET": "/var/run/clamav/clamd.ctl",
"REQUIRE_VIRUS_SCAN": False,
} }
INFRASYNTH_NOTIFICATIONS = { INFRASYNTH_NOTIFICATIONS = {
"DEFAULT_FROM_EMAIL": "noreply@example.com", "DEFAULT_FROM_EMAIL": "noreply@example.com",
"DEFAULT_FROM_SMS": "+1234567890",
"CHANNELS": { "CHANNELS": {
"email": { "email": {
"primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel",
@ -224,13 +263,13 @@ INFRASYNTH_NOTIFICATIONS = {
"DISPATCH_BACKEND": "celery", "DISPATCH_BACKEND": "celery",
"MAX_RETRIES": 3, "MAX_RETRIES": 3,
"RETRY_DELAY_SECONDS": [60, 300, 900], "RETRY_DELAY_SECONDS": [60, 300, 900],
"TEMPLATE_ENGINE": "django",
"RATE_LIMIT_PER_CHANNEL": { "RATE_LIMIT_PER_CHANNEL": {
"email": "50/m", "email": "50/m",
"sms": "10/m", "sms": "10/m",
}, },
"STORE_DISPATCH_LOGS": True, "STORE_DISPATCH_LOGS": True,
"DISPATCH_LOG_RETENTION_DAYS": 90, "DISPATCH_LOG_RETENTION_DAYS": 90,
"RETRY_SCAN_BATCH_SIZE": 100,
} }
INFRASYNTH_WEBHOOKS = { INFRASYNTH_WEBHOOKS = {
@ -241,6 +280,7 @@ INFRASYNTH_WEBHOOKS = {
"SIGNATURE_ALGORITHM": "sha256", "SIGNATURE_ALGORITHM": "sha256",
"SIGNATURE_HEADER": "X-Webhook-Signature", "SIGNATURE_HEADER": "X-Webhook-Signature",
"DELIVERY_BACKEND": "celery", "DELIVERY_BACKEND": "celery",
"INBOUND_PROCESSING_BACKEND": "sync",
"INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300, "INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300,
"MAX_PAYLOAD_SIZE_BYTES": 1048576, "MAX_PAYLOAD_SIZE_BYTES": 1048576,
} }
@ -271,13 +311,39 @@ INFRASYNTH_FEATURES = {
"CACHE_KEY_PREFIX": "features", "CACHE_KEY_PREFIX": "features",
"ROLLOUT_HASH_ALGORITHM": "md5", "ROLLOUT_HASH_ALGORITHM": "md5",
"AUTO_REGISTER_FROM_SETTINGS": True, "AUTO_REGISTER_FROM_SETTINGS": True,
"FLAGS": {},
"EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True, "EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True,
"EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True, "EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True,
} }
# Deployment environment used by feature-flag ``environments`` targeting.
ENVIRONMENT = "development"
INFRASYNTH_TENANCY = {
"ENABLED": True,
"TENANT_MODEL": "infrasynth.tenancy.Tenant",
"MEMBERSHIP_MODEL": "infrasynth.tenancy.TenantMembership",
"TENANT_CLAIM": "tenant",
"REQUIRE_TENANT_BY_DEFAULT": True,
"TENANT_ALLOWLIST_PATHS": [
"/api/v1/auth/login/",
"/api/v1/auth/refresh/",
"/api/v1/auth/select-workspace/",
"/api/v1/auth/altcha/",
"/api/v1/auth/2fa/",
"/api/v1/billing/webhook/",
"/api/v1/schema/",
"/api/v1/tenancy/invitations/accept/",
"/healthz",
"/readyz",
],
"ENABLE_WORKSPACE_SWITCHING": True,
"DEFAULT_LOCALE": "es",
"DEFAULT_TIMEZONE": "UTC",
}
INFRASYNTH_BILLING = { INFRASYNTH_BILLING = {
"INVOICE_NUMBER_PREFIX": "INV-", "INVOICE_NUMBER_PREFIX": "INV-",
"INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html",
"GRACE_PERIOD_DAYS": 5, "GRACE_PERIOD_DAYS": 5,
"MAX_RETRY_FAILED_PAYMENTS": 3, "MAX_RETRY_FAILED_PAYMENTS": 3,
"DEFAULT_CURRENCY": "USD", "DEFAULT_CURRENCY": "USD",
@ -286,4 +352,37 @@ INFRASYNTH_BILLING = {
"INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3, "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3,
"WEBHOOK_TOLERANCE_SECONDS": 300, "WEBHOOK_TOLERANCE_SECONDS": 300,
"SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24, "SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24,
"ENTITLEMENT_CACHE_TTL_SECONDS": 60,
}
# Periodic work. Every task binds the tenant(s) it touches explicitly.
CELERY_BEAT_SCHEDULE = {
"notifications-retry-pending": {
"task": "infrasynth.notifications.retry_pending_dispatches",
"schedule": 60.0,
},
"notifications-purge-old": {
"task": "infrasynth.notifications.purge_old_dispatches",
"schedule": 86400.0,
},
"billing-sync-subscriptions": {
"task": "infrasynth.billing.sync_subscriptions",
"schedule": cast(int, INFRASYNTH_BILLING["SYNC_SUBSCRIPTIONS_EVERY_HOURS"]) * 3600,
},
"billing-advance-lifecycle": {
"task": "infrasynth.billing.advance_entitlement_lifecycle",
"schedule": 3600.0,
},
"billing-expire-entitlements": {
"task": "infrasynth.billing.expire_entitlements",
"schedule": 3600.0,
},
"billing-generate-renewal-invoices": {
"task": "infrasynth.billing.generate_renewal_invoices",
"schedule": 86400.0,
},
"audit-purge-expired": {
"task": "infrasynth.audit.purge_expired_logs",
"schedule": 86400.0,
},
} }

View file

@ -1,7 +1,13 @@
import tempfile
from pathlib import Path
from .base import * # noqa: F403 from .base import * # noqa: F403
SECRET_KEY = "test-secret-key" SECRET_KEY = "test-secret-key"
# Keep every test artifact out of the repo tree.
MEDIA_ROOT = Path(tempfile.mkdtemp(prefix="infrasynth-test-media-"))
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
@ -24,3 +30,22 @@ INFRASYNTH_SECURITY["CRYPTO_KEY"] = "sBptcnWgrG5Tp8MJCnSoGQzZLb_4QPwNjuM4QNTGWe4
INFRASYNTH_SECURITY["COOKIE_SECURE"] = False INFRASYNTH_SECURITY["COOKIE_SECURE"] = False
INFRASYNTH_AUDIT["STORE_IN_DB"] = True INFRASYNTH_AUDIT["STORE_IN_DB"] = True
# Tests default to the pre-envelope wire format so the bulk of the suite asserts
# on raw payloads; tests/test_api enables the envelope/cursor layer explicitly.
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"infrasynth.security.auth.cookies.CookieJWTAuthentication",
"infrasynth.security.auth.api_keys.APIKeyAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 25,
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
# The tenant middleware is exercised by dedicated tests via override_settings.
INFRASYNTH_TENANCY["ENABLED"] = False
INFRASYNTH_TENANCY["REQUIRE_TENANT_BY_DEFAULT"] = False

View file

@ -3,13 +3,15 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/auth/", include("infrasynth.security.urls")), path("api/v1/", include("infrasynth.api.urls")),
path("api/audit/", include("infrasynth.audit.urls")), path("api/v1/tenancy/", include("infrasynth.tenancy.urls")),
path("api/files/", include("infrasynth.files.urls")), path("api/v1/auth/", include("infrasynth.security.urls")),
path("api/notifications/", include("infrasynth.notifications.urls")), path("api/v1/audit/", include("infrasynth.audit.urls")),
path("api/webhooks/", include("infrasynth.webhooks.urls")), path("api/v1/files/", include("infrasynth.files.urls")),
path("api/workflows/", include("infrasynth.workflows.urls")), path("api/v1/notifications/", include("infrasynth.notifications.urls")),
path("api/scheduler/", include("infrasynth.scheduler.urls")), path("api/v1/webhooks/", include("infrasynth.webhooks.urls")),
path("api/features/", include("infrasynth.features.urls")), path("api/v1/workflows/", include("infrasynth.workflows.urls")),
path("api/billing/", include("infrasynth.billing.urls")), path("api/v1/scheduler/", include("infrasynth.scheduler.urls")),
path("api/v1/features/", include("infrasynth.features.urls")),
path("api/v1/billing/", include("infrasynth.billing.urls")),
] ]

View file

@ -1,74 +0,0 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 646
>>
stream
Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000514 00000 n
0000000582 00000 n
0000000862 00000 n
0000000921 00000 n
trailer
<<
/ID
[<e98e62cc67699399dd11ec773ffbcc0f><e98e62cc67699399dd11ec773ffbcc0f>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1657
%%EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -1 +0,0 @@
hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data

View file

@ -0,0 +1,11 @@
"""``infrasynth.api`` — the DRF layer implementing ``API-STANDARD.md``.
This package is not a Django app; it is a set of renderers, pagination,
exception handling, and middleware that every InfraSynth service consumes so
the wire format is identical across apps.
"""
from .pagination import CursorPagination
from .renderers import EnvelopeJSONRenderer
__all__ = ["CursorPagination", "EnvelopeJSONRenderer"]

View file

@ -0,0 +1,117 @@
"""Envelope-aware DRF exception handler and error helpers (``API-STANDARD.md`` §4, §5)."""
from __future__ import annotations
from typing import Any
from django.http import JsonResponse
from rest_framework import status as http_status
from rest_framework.exceptions import (
APIException,
AuthenticationFailed,
NotAuthenticated,
NotFound,
PermissionDenied,
Throttled,
ValidationError,
)
from rest_framework.response import Response
from rest_framework.views import exception_handler as drf_exception_handler
from infrasynth.shared.exceptions import AppError
__all__ = ["envelope_exception_handler", "error_response", "envelope_body"]
def _flatten_details(detail: Any) -> list[dict[str, Any]]:
"""Turns a DRF ``detail`` (dict/list/str) into the standard details array."""
if isinstance(detail, dict):
flattened: list[dict[str, Any]] = []
for field, issue in detail.items():
if isinstance(issue, (list, tuple)):
for entry in issue:
flattened.append({"field": str(field), "issue": str(entry)})
else:
flattened.append({"field": str(field), "issue": str(issue)})
return flattened
if isinstance(detail, (list, tuple)):
return [{"issue": str(entry)} for entry in detail]
if detail is None:
return []
return [{"issue": str(detail)}]
def _map_exception(exc: APIException) -> tuple[str, str]:
if isinstance(exc, ValidationError):
return "VALIDATION_ERROR", "The request payload is invalid."
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
return "AUTH_UNAUTHENTICATED", "Authentication credentials were not provided or are invalid."
if isinstance(exc, PermissionDenied):
return "AUTH_FORBIDDEN", "You do not have permission to perform this action."
if isinstance(exc, NotFound):
return "NOT_FOUND", "The requested resource was not found."
if isinstance(exc, Throttled):
return "RATE_LIMIT_EXCEEDED", "Too many requests."
return "SERVER_ERROR", "An unexpected error occurred."
def envelope_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
"""DRF ``EXCEPTION_HANDLER`` producing ``{code, message, details}`` bodies.
The envelope itself is added by :class:`EnvelopeJSONRenderer` based on the
HTTP status. Unexpected (non-``APIException``, non-``AppError``) exceptions
return ``None`` so Django still surfaces them loudly instead of masking a
bug behind a generic 500.
"""
if isinstance(exc, AppError):
return Response(exc.to_dict(), status=exc.status)
response = drf_exception_handler(exc, context)
if response is None:
return None
code, message = _map_exception(exc) if isinstance(exc, APIException) else ("SERVER_ERROR", str(exc))
detail = getattr(exc, "detail", None)
response.data = {
"code": code,
"message": message,
"details": _flatten_details(detail),
}
return response
def envelope_body(
*,
success: bool,
data: Any = None,
error: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Builds an envelope dict for middleware that bypasses DRF views."""
return {"success": success, "data": data, "error": error, "meta": meta or {}}
def error_response(
code: str,
message: str,
*,
status_code: int = http_status.HTTP_403_FORBIDDEN,
details: list[dict[str, Any]] | None = None,
request: Any = None,
) -> JsonResponse:
"""Minimal envelope error for middleware (no DRF renderer in the chain)."""
from django.utils import timezone
body = envelope_body(
success=False,
error={"code": code, "message": message, "details": details or []},
meta={
"requestId": getattr(request, "request_id", "") if request else "",
"timestamp": timezone.now().isoformat().replace("+00:00", "Z"),
},
)
response = JsonResponse(body, status=status_code)
request_id = getattr(request, "request_id", "") if request else ""
if request_id:
response["X-Request-Id"] = request_id
return response

View file

@ -0,0 +1,56 @@
"""Idempotency-Key handling for state-mutating POSTs (``API-STANDARD.md`` §7)."""
from __future__ import annotations
import hashlib
from collections.abc import Callable
from functools import wraps
from typing import Any
from django.core.cache import cache
__all__ = ["idempotent"]
_TTL_SECONDS = 24 * 60 * 60
def _cache_key(request: Any, idempotency_key: str) -> str:
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
tenant_part = str(tenant.pk) if tenant is not None else "anon"
fingerprint = hashlib.sha256(f"{request.method}:{request.path}".encode()).hexdigest()[:16]
return f"tenant:{tenant_part}:idempotency:{idempotency_key}:{fingerprint}"
def idempotent(view_method: Callable[..., Any]) -> Callable[..., Any]:
"""Replays the first successful response for a repeated ``Idempotency-Key``.
Views that create real-world side effects (checkout, invitation acceptance)
apply this to the action method. Without the header the request is a no-op.
"""
@wraps(view_method)
def wrapper(self: Any, request: Any, *args: Any, **kwargs: Any) -> Any:
from rest_framework.response import Response
key = request.headers.get("Idempotency-Key")
if not key:
return view_method(self, request, *args, **kwargs)
cache_key = _cache_key(request, key)
cached = cache.get(cache_key)
if cached is not None:
response = Response(cached["data"], status=cached["status"])
response["Idempotent-Replay"] = "true"
return response
response = view_method(self, request, *args, **kwargs)
if 200 <= response.status_code < 300:
try:
cache.set(cache_key, {"data": response.data, "status": response.status_code}, _TTL_SECONDS)
except Exception: # noqa: BLE001 - never fail a request over cache serialization
pass
return response
return wrapper

View file

@ -0,0 +1,40 @@
"""Request correlation middleware (``API-STANDARD.md`` §7)."""
from __future__ import annotations
import uuid
from collections.abc import Callable
from django.http import HttpRequest, HttpResponse
__all__ = ["RequestIdMiddleware", "RateLimitHeadersMiddleware"]
_HEADER = "X-Request-Id"
class RequestIdMiddleware:
"""Ensures every request has an id, echoed in the response header and meta."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
request_id = request.headers.get(_HEADER) or f"req_{uuid.uuid4().hex[:20]}"
request.request_id = request_id # type: ignore[attr-defined]
response = self.get_response(request)
response[_HEADER] = request_id
return response
class RateLimitHeadersMiddleware:
"""Adds ``X-RateLimit-*`` headers whenever a throttle recorded state."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
from .throttling import apply_rate_limit_headers
response = self.get_response(request)
apply_rate_limit_headers(response, request)
return response

View file

@ -0,0 +1,34 @@
"""Opaque cursor pagination (``API-STANDARD.md`` §6)."""
from __future__ import annotations
from typing import Any
from rest_framework.pagination import CursorPagination as DRFCursorPagination
from rest_framework.response import Response
__all__ = ["CursorPagination"]
class CursorPagination(DRFCursorPagination):
"""Cursor pagination with a camelCase ``pageSize`` param and envelope meta.
Defaults to ordering by ``-pk`` so it works on every model out of the box;
views with a preferred ordering override the ``ordering`` attribute.
"""
page_size = 25
max_page_size = 100
page_size_query_param = "pageSize"
cursor_query_param = "cursor"
ordering = "-pk"
def get_paginated_response(self, data: Any) -> Response:
return Response(
{
"next": self.get_next_link(),
"previous": self.get_previous_link(),
"page_size": self.page_size,
"results": data,
}
)

View file

@ -0,0 +1,58 @@
"""Envelope + camelCase renderer (``API-STANDARD.md`` §3, §4)."""
from __future__ import annotations
from typing import Any
from django.utils import timezone
from djangorestframework_camel_case.render import CamelCaseJSONRenderer
__all__ = ["EnvelopeJSONRenderer"]
_PAGINATED_KEYS = {"results", "next", "previous"}
class EnvelopeJSONRenderer(CamelCaseJSONRenderer):
"""Wraps every response in the standard envelope and camelCases its keys.
* success (status < 400): ``{success: true, data, error: null, meta}``
* error (status >= 400): ``{success: false, data: null, error, meta}``
``meta`` always carries ``requestId`` and ``timestamp``; it carries
``tenantId`` when a tenant is resolved and ``pagination`` for list
responses produced by :class:`infrasynth.api.pagination.CursorPagination`.
"""
def render(
self,
data: Any,
accepted_media_type: str | None = None,
renderer_context: dict[str, Any] | None = None,
) -> bytes:
renderer_context = renderer_context or {}
response = renderer_context.get("response")
request = renderer_context.get("request")
status_code = getattr(response, "status_code", 200) or 200
meta: dict[str, Any] = {
"requestId": getattr(request, "request_id", "") if request else "",
"timestamp": timezone.now().isoformat().replace("+00:00", "Z"),
}
tenant = getattr(request, "tenant", None) if request else None
if tenant is not None:
meta["tenantId"] = str(getattr(tenant, "pk", tenant))
if status_code >= 400:
body: dict[str, Any] = {"success": False, "data": None, "error": data, "meta": meta}
else:
payload = data
if isinstance(payload, dict) and _PAGINATED_KEYS.issubset(payload.keys()):
meta["pagination"] = {
"nextCursor": payload.get("next"),
"prevCursor": payload.get("previous"),
"pageSize": payload.get("page_size"),
}
payload = payload.get("results")
body = {"success": True, "data": payload, "error": None, "meta": meta}
return super().render(body, accepted_media_type, renderer_context)

View file

@ -0,0 +1,58 @@
"""Tenant-scoped throttling + standard rate-limit headers (``API-STANDARD.md`` §9)."""
from __future__ import annotations
import time
from typing import Any
from rest_framework.throttling import SimpleRateThrottle
__all__ = ["TenantRateThrottle", "apply_rate_limit_headers"]
class TenantRateThrottle(SimpleRateThrottle):
"""Throttles per tenant *and* per identity, never per IP alone (``TENANCY.md`` §7).
Requires a ``DEFAULT_THROTTLE_RATES["tenant"]`` rate; when it is absent the
throttle is a no-op so the kit still runs with zero configuration.
"""
scope = "tenant"
def get_rate(self) -> str | None: # type: ignore[override]
from django.conf import settings
rates = getattr(settings, "DEFAULT_THROTTLE_RATES", {})
return rates.get(self.scope)
def get_cache_key(self, request: Any, view: Any) -> str | None:
if not self.rate:
return None
from infrasynth.tenancy.context import get_current_tenant
tenant = get_current_tenant()
tenant_part = str(tenant.pk) if tenant is not None else "anon"
ident = self.get_ident(request)
return f"tenant:{tenant_part}:ratelimit:{self.scope}:{ident}"
def allow_request(self, request: Any, view: Any) -> bool:
allowed = super().allow_request(request, view)
history = getattr(self, "history", [])
num_requests = getattr(self, "num_requests", 0)
if num_requests:
request._rate_limit = { # type: ignore[attr-defined]
"limit": num_requests,
"remaining": max(0, num_requests - len(history)),
"reset": int(history[-1]) if history else int(time.time()),
}
return allowed
def apply_rate_limit_headers(response: Any, request: Any) -> None:
"""Copies throttle state recorded on the request onto the response headers."""
info = getattr(request, "_rate_limit", None)
if not info:
return
response["X-RateLimit-Limit"] = str(info["limit"])
response["X-RateLimit-Remaining"] = str(info["remaining"])
response["X-RateLimit-Reset"] = str(info["reset"])

13
infrasynth/api/urls.py Normal file
View file

@ -0,0 +1,13 @@
"""Live OpenAPI schema endpoints (``API-STANDARD.md`` §12)."""
from __future__ import annotations
from django.urls import path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
__all__ = ["urlpatterns"]
urlpatterns = [
path("schema/", SpectacularAPIView.as_view(), name="schema"),
path("schema/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="schema-docs"),
]

View file

@ -0,0 +1,34 @@
"""Shared webhook hardening helpers (``API-STANDARD.md`` §10)."""
from __future__ import annotations
import time
from typing import Any
from infrasynth.shared.exceptions import ValidationAppError
__all__ = ["assert_fresh_webhook"]
def assert_fresh_webhook(timestamp: Any, *, tolerance_seconds: int = 300) -> None:
"""Rejects a webhook whose timestamp is older than the tolerance window.
Replay protection is independent of signature validity: a correctly signed
event replayed after the window is still rejected.
"""
try:
event_ts = float(timestamp)
except (TypeError, ValueError) as exc:
raise ValidationAppError(
"Webhook timestamp is missing or invalid.",
code="VALIDATION_WEBHOOK_TIMESTAMP",
status=400,
) from exc
if abs(time.time() - event_ts) > tolerance_seconds:
raise ValidationAppError(
"Webhook timestamp is outside the accepted tolerance window.",
code="VALIDATION_WEBHOOK_STALE",
status=400,
details=[{"issue": "stale", "toleranceSeconds": tolerance_seconds}],
)

View file

@ -9,7 +9,8 @@ from .models import APIInteractionLog
class AuditAPIMiddleware(MiddlewareMixin): class AuditAPIMiddleware(MiddlewareMixin):
def process_request(self, request): def process_request(self, request):
request.request_id = str(uuid.uuid4()) if not getattr(request, "request_id", None):
request.request_id = str(uuid.uuid4())
request._audit_start_time = time.time() request._audit_start_time = time.time()
def process_response(self, request, response): def process_response(self, request, response):
@ -64,17 +65,26 @@ class AuditAPIMiddleware(MiddlewareMixin):
if actor is not None and not hasattr(actor, "_meta"): if actor is not None and not hasattr(actor, "_meta"):
actor = None actor = None
APIInteractionLog.objects.create( from infrasynth.tenancy.context import get_current_tenant
method=request.method,
path=path, tenant = get_current_tenant()
status_code=response.status_code, try:
request_body=request_body, APIInteractionLog.objects.create(
response_body=response_body, tenant=tenant,
ip_address=request.META.get("REMOTE_ADDR"), method=request.method,
actor=actor, path=path,
duration_ms=duration_ms, status_code=response.status_code,
request_id=getattr(request, "request_id", ""), request_body=request_body,
user_agent=request.META.get("HTTP_USER_AGENT", ""), response_body=response_body,
) ip_address=request.META.get("REMOTE_ADDR"),
actor=actor,
duration_ms=duration_ms,
request_id=getattr(request, "request_id", ""),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
except Exception: # noqa: BLE001 - audit must never break the response
import logging
logging.getLogger(__name__).exception("Failed to persist APIInteractionLog")
return response return response

View file

@ -1,4 +1,4 @@
# Generated by Django 5.2.16 on 2026-07-31 01:19 # Generated by Django 5.2.17 on 2026-09-24 14:10
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
@ -9,6 +9,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("tenancy", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@ -25,7 +26,7 @@ class Migration(migrations.Migration):
("ip_address", models.GenericIPAddressField(null=True)), ("ip_address", models.GenericIPAddressField(null=True)),
("duration_ms", models.PositiveIntegerField()), ("duration_ms", models.PositiveIntegerField()),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
("request_id", models.CharField(max_length=64, unique=True)), ("request_id", models.CharField(db_index=True, max_length=64)),
("user_agent", models.TextField(blank=True, default="")), ("user_agent", models.TextField(blank=True, default="")),
( (
"actor", "actor",
@ -33,29 +34,20 @@ class Migration(migrations.Migration):
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
), ),
), ),
],
options={
"db_table": "audit_api_interaction_log",
},
),
migrations.CreateModel(
name="SecurityEvent",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("event_type", models.CharField(db_index=True, max_length=50)),
("ip_address", models.GenericIPAddressField(null=True)),
("metadata", models.JSONField(default=dict)),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
("request_id", models.CharField(max_length=64)),
( (
"actor", "tenant",
models.ForeignKey( models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
), ),
), ),
], ],
options={ options={
"db_table": "audit_security_event", "db_table": "audit_api_interaction_log",
"indexes": [models.Index(fields=["tenant_id", "timestamp"], name="audit_api_i_tenant__e12b1e_idx")],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
@ -79,13 +71,57 @@ class Migration(migrations.Migration):
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
), ),
), ),
(
"tenant",
models.ForeignKey(
blank=True,
help_text="Null = platform action; set for tenant-scoped actions.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
], ],
options={ options={
"db_table": "audit_model_change_log", "db_table": "audit_model_change_log",
"indexes": [ "indexes": [
models.Index(fields=["model_label", "object_id"], name="audit_model_model_l_923061_idx"), models.Index(
models.Index(fields=["timestamp"], name="audit_model_timesta_4429ca_idx"), fields=["tenant_id", "model_label", "object_id"], name="audit_model_tenant__a189da_idx"
),
models.Index(fields=["tenant_id", "timestamp"], name="audit_model_tenant__3c17f6_idx"),
], ],
}, },
), ),
migrations.CreateModel(
name="SecurityEvent",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("event_type", models.CharField(db_index=True, max_length=50)),
("ip_address", models.GenericIPAddressField(null=True)),
("metadata", models.JSONField(default=dict)),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
("request_id", models.CharField(max_length=64)),
(
"actor",
models.ForeignKey(
null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL
),
),
(
"tenant",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
],
options={
"db_table": "audit_security_event",
"indexes": [models.Index(fields=["tenant_id", "event_type"], name="audit_secur_tenant__64d72b_idx")],
},
),
] ]

View file

@ -1,8 +1,18 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from infrasynth.tenancy.managers import AllObjectsManager
class ModelChangeLog(models.Model): class ModelChangeLog(models.Model):
tenant = models.ForeignKey(
"tenancy.Tenant",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="+",
help_text="Null = platform action; set for tenant-scoped actions.",
)
model_label = models.CharField(max_length=200, db_index=True) model_label = models.CharField(max_length=200, db_index=True)
object_id = models.CharField(max_length=200, db_index=True) object_id = models.CharField(max_length=200, db_index=True)
action = models.CharField( action = models.CharField(
@ -17,12 +27,21 @@ class ModelChangeLog(models.Model):
class Meta: class Meta:
db_table = "audit_model_change_log" db_table = "audit_model_change_log"
indexes = [ indexes = [
models.Index(fields=["model_label", "object_id"]), models.Index(fields=["tenant_id", "model_label", "object_id"]),
models.Index(fields=["timestamp"]), models.Index(fields=["tenant_id", "timestamp"]),
] ]
objects = AllObjectsManager()
class APIInteractionLog(models.Model): class APIInteractionLog(models.Model):
tenant = models.ForeignKey(
"tenancy.Tenant",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="+",
)
method = models.CharField(max_length=10, db_index=True) method = models.CharField(max_length=10, db_index=True)
path = models.CharField(max_length=500, db_index=True) path = models.CharField(max_length=500, db_index=True)
status_code = models.PositiveSmallIntegerField(db_index=True) status_code = models.PositiveSmallIntegerField(db_index=True)
@ -32,14 +51,24 @@ class APIInteractionLog(models.Model):
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
duration_ms = models.PositiveIntegerField() duration_ms = models.PositiveIntegerField()
timestamp = models.DateTimeField(auto_now_add=True, db_index=True) timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
request_id = models.CharField(max_length=64, unique=True) request_id = models.CharField(max_length=64, db_index=True)
user_agent = models.TextField(blank=True, default="") user_agent = models.TextField(blank=True, default="")
class Meta: class Meta:
db_table = "audit_api_interaction_log" db_table = "audit_api_interaction_log"
indexes = [models.Index(fields=["tenant_id", "timestamp"])]
objects = AllObjectsManager()
class SecurityEvent(models.Model): class SecurityEvent(models.Model):
tenant = models.ForeignKey(
"tenancy.Tenant",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="+",
)
event_type = models.CharField(max_length=50, db_index=True) event_type = models.CharField(max_length=50, db_index=True)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
ip_address = models.GenericIPAddressField(null=True) ip_address = models.GenericIPAddressField(null=True)
@ -49,3 +78,6 @@ class SecurityEvent(models.Model):
class Meta: class Meta:
db_table = "audit_security_event" db_table = "audit_security_event"
indexes = [models.Index(fields=["tenant_id", "event_type"])]
objects = AllObjectsManager()

View file

@ -1,13 +1,19 @@
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import ModelChangeLog, SecurityEvent from .models import ModelChangeLog, SecurityEvent
from .signals import security_event_occurred from .signals import security_event_occurred
def _current_tenant():
from infrasynth.tenancy.context import get_current_tenant
return get_current_tenant()
def _get_excluded_models(): def _get_excluded_models():
config = getattr(settings, "INFRASYNTH_AUDIT", {}) config = getattr(settings, "INFRASYNTH_AUDIT", {})
return set(config.get("EXCLUDED_MODELS", [])) return set(config.get("EXCLUDED_MODELS", []))
@ -24,6 +30,34 @@ def _get_request_id(request=None):
return str(uuid.uuid4())[:8] return str(uuid.uuid4())[:8]
def _store_enabled() -> bool:
return bool(getattr(settings, "INFRASYNTH_AUDIT", {}).get("STORE_IN_DB", True))
@receiver(pre_save)
def capture_previous_state(sender, instance, raw, **kwargs):
"""Snapshots the current DB row onto ``instance._previous_state`` before save.
This is what lets :func:`track_model_change` compute an update diff without
requiring every domain model to opt in. Creates (no existing row) are left
alone. Excluded models are skipped so the audit tables never audit themselves.
"""
if raw or instance.pk is None:
return
config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return
if sender._meta.label in _get_excluded_models():
return
manager = getattr(sender, "all_objects", None) or sender._base_manager
try:
previous = manager.filter(pk=instance.pk).first()
except Exception: # noqa: BLE001 - audit must never break a write
return
if previous is not None:
instance._previous_state = previous
@receiver(post_save) @receiver(post_save)
def track_model_change(sender, instance, created, raw, **kwargs): def track_model_change(sender, instance, created, raw, **kwargs):
if raw: if raw:
@ -34,9 +68,12 @@ def track_model_change(sender, instance, created, raw, **kwargs):
config = getattr(settings, "INFRASYNTH_AUDIT", {}) config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return return
if not _store_enabled():
return
if created: if created:
ModelChangeLog.objects.create( ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label, model_label=label,
object_id=str(instance.pk), object_id=str(instance.pk),
action="create", action="create",
@ -49,6 +86,7 @@ def track_model_change(sender, instance, created, raw, **kwargs):
changes = _compute_changes(instance._previous_state, instance) changes = _compute_changes(instance._previous_state, instance)
if changes: if changes:
ModelChangeLog.objects.create( ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label, model_label=label,
object_id=str(instance.pk), object_id=str(instance.pk),
action="update", action="update",
@ -66,8 +104,11 @@ def track_model_delete(sender, instance, **kwargs):
config = getattr(settings, "INFRASYNTH_AUDIT", {}) config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True):
return return
if not _store_enabled():
return
ModelChangeLog.objects.create( ModelChangeLog.objects.create(
tenant=_current_tenant(),
model_label=label, model_label=label,
object_id=str(instance.pk), object_id=str(instance.pk),
action="delete", action="delete",
@ -121,7 +162,10 @@ def log_security_event(sender, **kwargs):
config = getattr(settings, "INFRASYNTH_AUDIT", {}) config = getattr(settings, "INFRASYNTH_AUDIT", {})
if not config.get("ENABLE_SECURITY_EVENTS", True): if not config.get("ENABLE_SECURITY_EVENTS", True):
return return
if not _store_enabled():
return
SecurityEvent.objects.create( SecurityEvent.objects.create(
tenant=_current_tenant(),
event_type=kwargs.get("event_type", "unknown"), event_type=kwargs.get("event_type", "unknown"),
actor=kwargs.get("actor"), actor=kwargs.get("actor"),
ip_address=kwargs.get("ip_address"), ip_address=kwargs.get("ip_address"),

38
infrasynth/audit/tasks.py Normal file
View file

@ -0,0 +1,38 @@
"""Retention jobs for audit data (``INFRASYNTH_AUDIT.RETENTION_DAYS``)."""
from __future__ import annotations
import logging
from datetime import timedelta
from celery import shared_task
from django.utils import timezone
from infrasynth.shared.settings_utils import get_setting
logger = logging.getLogger(__name__)
@shared_task(name="infrasynth.audit.purge_expired_logs")
def purge_expired_logs(tenant_id=None):
"""Deletes audit rows older than the configured retention window.
``RETENTION_DAYS <= 0`` disables retention (rows are kept forever).
Returns a ``{model_label: deleted_count}`` mapping.
"""
retention_days = int(get_setting("INFRASYNTH_AUDIT", "RETENTION_DAYS", 365))
if retention_days <= 0:
return {}
from .models import APIInteractionLog, ModelChangeLog, SecurityEvent
cutoff = timezone.now() - timedelta(days=retention_days)
deleted: dict[str, int] = {}
for model in (ModelChangeLog, APIInteractionLog, SecurityEvent):
qs = model.objects.filter(timestamp__lt=cutoff)
if tenant_id:
qs = qs.filter(tenant_id=tenant_id)
count, _ = qs.delete()
deleted[model._meta.label] = count
logger.info("Audit retention purge removed %s", deleted)
return deleted

View file

@ -1,5 +1,7 @@
from rest_framework import mixins, viewsets from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticated
from infrasynth.security.permissions import IsAuthenticatedAndPermitted
from infrasynth.tenancy.context import get_current_tenant
from .filters import APIInteractionLogFilter, ModelChangeLogFilter, SecurityEventFilter from .filters import APIInteractionLogFilter, ModelChangeLogFilter, SecurityEventFilter
from .models import APIInteractionLog, ModelChangeLog, SecurityEvent from .models import APIInteractionLog, ModelChangeLog, SecurityEvent
@ -10,31 +12,54 @@ from .serializers import (
) )
class ModelChangeLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class _AuditScopedViewSet:
queryset = ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() """Audit rows are tenant-filtered for tenants; platform staff see all."""
def _scoped(self, model):
from infrasynth.tenancy.services import TenantService
qs = model.objects.select_related("actor").order_by("-timestamp")
user = getattr(self.request, "user", None)
if user is not None and getattr(user, "is_authenticated", False):
if getattr(user, "is_superuser", False) or TenantService().is_platform_staff(user):
return qs
tenant = get_current_tenant()
if tenant is None:
return qs.none()
return qs.filter(tenant_id=tenant.pk)
class ModelChangeLogViewSet(
_AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
serializer_class = ModelChangeLogSerializer serializer_class = ModelChangeLogSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticatedAndPermitted]
required_permissions = ["audit.view_model_changes"]
filterset_class = ModelChangeLogFilter filterset_class = ModelChangeLogFilter
def get_queryset(self): def get_queryset(self):
return ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() return self._scoped(ModelChangeLog)
class APIInteractionLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class APIInteractionLogViewSet(
queryset = APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() _AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
serializer_class = APIInteractionLogSerializer serializer_class = APIInteractionLogSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticatedAndPermitted]
required_permissions = ["audit.view_api_logs"]
filterset_class = APIInteractionLogFilter filterset_class = APIInteractionLogFilter
def get_queryset(self): def get_queryset(self):
return APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() return self._scoped(APIInteractionLog)
class SecurityEventViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): class SecurityEventViewSet(
queryset = SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() _AuditScopedViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
serializer_class = SecurityEventSerializer serializer_class = SecurityEventSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticatedAndPermitted]
required_permissions = ["audit.view_security_events"]
filterset_class = SecurityEventFilter filterset_class = SecurityEventFilter
def get_queryset(self): def get_queryset(self):
return SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() return self._scoped(SecurityEvent)

View file

@ -0,0 +1,101 @@
"""``EntitlementService`` — in-process commercial enforcement (``ENTITLEMENTS.md`` §4)."""
from __future__ import annotations
from typing import Any
from django.core.cache import cache
from infrasynth.shared.settings_utils import get_setting
from .models import Entitlement
__all__ = ["EntitlementService"]
_ENTITLED_STATUSES = {"trialing", "active", "past_due", "grace"}
_TENANT_OK_STATUSES = {"active", "trialing"}
class EntitlementService:
"""The only gate for commercial access. Feature flags never replace it."""
def __init__(self) -> None:
self.ttl = int(get_setting("INFRASYNTH_BILLING", "ENTITLEMENT_CACHE_TTL_SECONDS", 60))
def _cache_key(self, tenant: Any, app_slug: str) -> str:
return f"tenant:{tenant.pk}:entitlements:{app_slug}"
def get(self, tenant: Any, app_slug: str) -> Entitlement | None:
if tenant is None:
return None
key = self._cache_key(tenant, app_slug)
cached = cache.get(key)
if cached is not None:
return cached if isinstance(cached, Entitlement) else None
entitlement = (
Entitlement.all_objects.filter(tenant=tenant, app__slug=app_slug).select_related("plan", "app").first()
)
cache.set(key, entitlement, self.ttl)
return entitlement
def is_entitled(self, tenant: Any, app_slug: str, *, feature: str | None = None) -> bool:
if tenant is None:
return False
# Tenant status gates before entitlement state (ENTITLEMENTS.md §4).
if getattr(tenant, "status", None) not in _TENANT_OK_STATUSES:
return False
entitlement = self.get(tenant, app_slug)
if entitlement is None:
return False
if entitlement.status not in _ENTITLED_STATUSES:
return False
if feature is None:
return True
return bool(self._features(entitlement).get(feature, False))
@staticmethod
def _features(entitlement: Entitlement) -> dict:
"""``plan.features`` merged under any entitlement-level override."""
features: dict = {}
if entitlement.plan:
features.update(entitlement.plan.features or {})
override = (entitlement.metadata or {}).get("features")
if isinstance(override, dict):
features.update(override)
return features
def check_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> bool:
"""True when ``current`` is within the plan's ``limit`` (None = unlimited)."""
entitlement = self.get(tenant, app_slug)
if entitlement is None or entitlement.plan is None:
return False
max_value = (entitlement.plan.limits or {}).get(limit)
if max_value is None:
return True
return current < int(max_value)
def require_limit(self, tenant: Any, app_slug: str, limit: str, current: int) -> None:
"""Raises :class:`EntitlementError` when the limit would be exceeded."""
if self.check_limit(tenant, app_slug, limit, current):
return
from infrasynth.shared.exceptions import ENTITLEMENT_LIMIT_REACHED, EntitlementError
entitlement = self.get(tenant, app_slug)
max_value = (entitlement.plan.limits or {}).get(limit) if entitlement and entitlement.plan else None
raise EntitlementError(
code=ENTITLEMENT_LIMIT_REACHED,
app=app_slug,
limit=limit,
current=current,
max=max_value,
)
def invalidate(self, tenant: Any, app_slug: str | None = None) -> None:
if tenant is None:
return
if app_slug is not None:
cache.delete(self._cache_key(tenant, app_slug))
return
slugs = Entitlement.all_objects.filter(tenant=tenant).values_list("app__slug", flat=True)
for slug in set(slugs):
cache.delete(self._cache_key(tenant, slug))

View file

@ -1,10 +1,12 @@
import django_filters import django_filters
from .models import ( from .models import (
BillingPlan, App,
Entitlement,
Invoice, Invoice,
PaymentGateway, PaymentGateway,
PaymentTransaction, PaymentTransaction,
Plan,
Subscription, Subscription,
) )
@ -12,45 +14,46 @@ from .models import (
class PaymentGatewayFilter(django_filters.FilterSet): class PaymentGatewayFilter(django_filters.FilterSet):
class Meta: class Meta:
model = PaymentGateway model = PaymentGateway
fields = { fields = {"is_active": ["exact"]}
"is_active": ["exact"],
}
class BillingPlanFilter(django_filters.FilterSet): class AppFilter(django_filters.FilterSet):
class Meta: class Meta:
model = BillingPlan model = App
fields = {"slug": ["exact"], "monetization": ["exact"], "is_active": ["exact"]}
class PlanFilter(django_filters.FilterSet):
class Meta:
model = Plan
fields = { fields = {
"app": ["exact"],
"app__slug": ["exact"],
"slug": ["exact"], "slug": ["exact"],
"interval": ["exact"], "interval": ["exact"],
"is_active": ["exact"], "is_active": ["exact"],
} }
class EntitlementFilter(django_filters.FilterSet):
class Meta:
model = Entitlement
fields = {"app": ["exact"], "app__slug": ["exact"], "plan": ["exact"], "status": ["exact"]}
class SubscriptionFilter(django_filters.FilterSet): class SubscriptionFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Subscription model = Subscription
fields = { fields = {"entitlement": ["exact"], "plan": ["exact"], "status": ["exact"]}
"user": ["exact"],
"plan": ["exact"],
"status": ["exact"],
}
class InvoiceFilter(django_filters.FilterSet): class InvoiceFilter(django_filters.FilterSet):
class Meta: class Meta:
model = Invoice model = Invoice
fields = { fields = {"subscription": ["exact"], "status": ["exact"]}
"user": ["exact"],
"subscription": ["exact"],
"status": ["exact"],
}
class PaymentTransactionFilter(django_filters.FilterSet): class PaymentTransactionFilter(django_filters.FilterSet):
class Meta: class Meta:
model = PaymentTransaction model = PaymentTransaction
fields = { fields = {"invoice": ["exact"], "status": ["exact"]}
"invoice": ["exact"],
"status": ["exact"],
}

View file

@ -18,7 +18,7 @@ class WebhookResult:
class BasePaymentGateway(ABC): class BasePaymentGateway(ABC):
@abstractmethod @abstractmethod
def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: ... def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult: ...
@abstractmethod @abstractmethod
def handle_webhook(self, payload, headers) -> WebhookResult: ... def handle_webhook(self, payload, headers) -> WebhookResult: ...

View file

@ -26,6 +26,7 @@ class MercadoPagoGateway(BasePaymentGateway):
def __init__(self, config: dict | None = None): def __init__(self, config: dict | None = None):
config = {str(key).lower(): value for key, value in (config or {}).items()} config = {str(key).lower(): value for key, value in (config or {}).items()}
self.access_token = config.get("access_token") self.access_token = config.get("access_token")
self.webhook_secret = config.get("webhook_secret")
self._sdk = None self._sdk = None
@property @property
@ -36,7 +37,7 @@ class MercadoPagoGateway(BasePaymentGateway):
self._sdk = mercadopago.SDK(self.access_token) self._sdk = mercadopago.SDK(self.access_token)
return self._sdk return self._sdk
def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult:
self._require_credentials() self._require_credentials()
preference = { preference = {
"items": [ "items": [
@ -44,7 +45,7 @@ class MercadoPagoGateway(BasePaymentGateway):
"title": plan.name, "title": plan.name,
"quantity": 1, "quantity": 1,
"currency_id": plan.price_currency, "currency_id": plan.price_currency,
"unit_price": float(plan.price_amount), "unit_price": int(plan.price_amount),
} }
], ],
"back_urls": { "back_urls": {
@ -54,7 +55,12 @@ class MercadoPagoGateway(BasePaymentGateway):
}, },
"auto_return": "approved", "auto_return": "approved",
"notification_url": kwargs.get("notification_url") or "", "notification_url": kwargs.get("notification_url") or "",
"metadata": {"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, "metadata": {
"plan_slug": plan.slug,
"app_slug": getattr(getattr(plan, "app", None), "slug", ""),
"user_id": str(getattr(user, "pk", "")),
"tenant_id": str(getattr(tenant, "pk", "")),
},
} }
result = self.sdk.preference().create(preference) result = self.sdk.preference().create(preference)
if result.get("status") != 201: if result.get("status") != 201:
@ -67,14 +73,35 @@ class MercadoPagoGateway(BasePaymentGateway):
) )
def handle_webhook(self, payload, headers) -> WebhookResult: def handle_webhook(self, payload, headers) -> WebhookResult:
event_type = payload.get("type") or "payment" event_type = payload.get("type") or payload.get("action") or "payment"
data = payload.get("data") or payload data = payload.get("data") or payload
# MercadoPago signs a manifest of id/request-id/ts with HMAC-SHA256.
if self.webhook_secret:
if not self._verify_signature(payload, headers):
return WebhookResult(event_type=event_type, is_handled=False, data=data)
return WebhookResult( return WebhookResult(
event_type=event_type, event_type=event_type,
is_handled=True, is_handled=True,
data=data, data=data,
) )
def _verify_signature(self, payload, headers) -> bool:
import hashlib
import hmac
secret = self.webhook_secret or ""
signature = headers.get("x-signature") or headers.get("X-Signature") or ""
request_id = headers.get("x-request-id") or headers.get("X-Request-Id") or ""
parts = dict(part.split("=", 1) for part in signature.split(",") if "=" in part)
ts = parts.get("ts", "")
v1 = parts.get("v1", "")
if not ts or not v1:
return False
data_id = str((payload.get("data") or {}).get("id") or "")
manifest = f"id:{data_id};request-id:{request_id};ts:{ts};"
expected = hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
def cancel_subscription(self, subscription) -> bool: def cancel_subscription(self, subscription) -> bool:
self._require_credentials() self._require_credentials()
if not subscription.external_id: if not subscription.external_id:

View file

@ -38,7 +38,7 @@ class StripeGateway(BasePaymentGateway):
if self.api_key: if self.api_key:
stripe.api_key = self.api_key stripe.api_key = self.api_key
def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult:
self._require_credentials() self._require_credentials()
if not plan.external_id: if not plan.external_id:
raise ValueError("Plan has no external price ID configured for Stripe") raise ValueError("Plan has no external price ID configured for Stripe")
@ -48,7 +48,12 @@ class StripeGateway(BasePaymentGateway):
success_url=kwargs.get("success_url") or "https://example.com/success", success_url=kwargs.get("success_url") or "https://example.com/success",
cancel_url=kwargs.get("cancel_url") or "https://example.com/cancel", cancel_url=kwargs.get("cancel_url") or "https://example.com/cancel",
customer_email=str(getattr(user, "email", "") or ""), customer_email=str(getattr(user, "email", "") or ""),
metadata={"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, metadata={
"plan_slug": plan.slug,
"app_slug": getattr(getattr(plan, "app", None), "slug", ""),
"user_id": str(getattr(user, "pk", "")),
"tenant_id": str(getattr(tenant, "pk", "")),
},
) )
return CheckoutSessionResult( return CheckoutSessionResult(
session_id=session.id, session_id=session.id,

View file

@ -47,12 +47,12 @@ class WompiGateway(BasePaymentGateway):
self.base_url = config.get("base_url") or self.BASE_URLS.get(environment, self.BASE_URLS["sandbox"]) self.base_url = config.get("base_url") or self.BASE_URLS.get(environment, self.BASE_URLS["sandbox"])
self.timeout = config.get("timeout") or 30 self.timeout = config.get("timeout") or 30
def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: def create_checkout_session(self, plan, user=None, *, tenant=None, **kwargs) -> CheckoutSessionResult:
if not self.public_key: if not self.public_key:
raise ValueError("Wompi public key not configured") raise ValueError("Wompi public key not configured")
payload = { payload = {
"name": plan.name, "name": plan.name,
"amount_in_cents": int(round(float(plan.price_amount) * 100)), "amount_in_cents": int(plan.price_amount),
"currency": plan.price_currency.lower(), "currency": plan.price_currency.lower(),
"single_use": True, "single_use": True,
"redirect_url": kwargs.get("success_url") or "https://example.com/success", "redirect_url": kwargs.get("success_url") or "https://example.com/success",

View file

@ -12,41 +12,45 @@ logger = logging.getLogger(__name__)
max_retries=3, max_retries=3,
default_retry_delay=60, default_retry_delay=60,
) )
def generate_invoice_pdf(self, invoice_id): def generate_invoice_pdf(self, invoice_id, tenant_id=None):
"""Generates a PDF for an invoice and stores it via the files service.""" """Generates a PDF for an invoice and stores it via the files service."""
from infrasynth.tenancy.context import tenant_context
from infrasynth.tenancy.models import Tenant
from .models import Invoice from .models import Invoice
try: tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None
invoice = Invoice.objects.select_related("user", "subscription", "subscription__plan", "gateway").get( with tenant_context(tenant):
pk=invoice_id try:
invoice = Invoice.all_objects.select_related("subscription", "subscription__plan", "gateway").get(
pk=invoice_id
)
except Invoice.DoesNotExist:
logger.warning("Invoice %s not found", invoice_id)
return None
try:
pdf_bytes = _build_invoice_pdf(invoice)
except Exception as exc: # noqa: BLE001
logger.exception("PDF generation failed for invoice %s", invoice_id)
raise self.retry(exc=exc) from exc
from django.core.files.base import ContentFile
from infrasynth.files.services import FileService
filename = f"invoice_{invoice.invoice_number}.pdf"
content = ContentFile(pdf_bytes, name=filename)
content.content_type = "application/pdf"
stored = FileService().upload(
content,
filename=filename,
metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number},
) )
except Invoice.DoesNotExist:
logger.warning("Invoice %s not found", invoice_id)
return None
try: invoice.pdf_file = stored
pdf_bytes = _build_invoice_pdf(invoice) invoice.save(update_fields=["pdf_file"])
except Exception as exc: # noqa: BLE001 return invoice.id
logger.exception("PDF generation failed for invoice %s", invoice_id)
raise self.retry(exc=exc) from exc
from django.core.files.base import ContentFile
from infrasynth.files.services import FileService
filename = f"invoice_{invoice.invoice_number}.pdf"
content = ContentFile(pdf_bytes, name=filename)
content.content_type = "application/pdf"
stored = FileService().upload(
content,
filename=filename,
user=invoice.user,
metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number},
)
invoice.pdf_file = stored
invoice.save(update_fields=["pdf_file"])
return invoice.id
def _build_invoice_pdf(invoice) -> bytes: def _build_invoice_pdf(invoice) -> bytes:
@ -86,7 +90,7 @@ def _build_invoice_pdf(invoice) -> bytes:
Spacer(1, 12), Spacer(1, 12),
Paragraph("<b>Cliente</b>", body_style), Paragraph("<b>Cliente</b>", body_style),
Paragraph( Paragraph(
f"{invoice.user.get_full_name() or invoice.user.username}<br/>{invoice.user.email}", f"{getattr(invoice.tenant, 'name', '')}",
body_style, body_style,
), ),
Spacer(1, 12), Spacer(1, 12),

View file

@ -1,7 +1,6 @@
# Generated by Django 5.2.16 on 2026-07-31 01:19 # Generated by Django 5.2.17 on 2026-09-24 14:10
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import infrasynth.shared.enums import infrasynth.shared.enums
@ -12,10 +11,27 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("infrasynth_files", "0001_initial"), ("infrasynth_files", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("tenancy", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel(
name="App",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("slug", models.SlugField(max_length=100, unique=True)),
("name", models.CharField(max_length=200)),
(
"monetization",
models.CharField(choices=infrasynth.shared.enums.MonetizationModel.choices, max_length=20),
),
("is_active", models.BooleanField(default=True)),
("metadata", models.JSONField(default=dict)),
],
options={
"db_table": "billing_app",
},
),
migrations.CreateModel( migrations.CreateModel(
name="PaymentGateway", name="PaymentGateway",
fields=[ fields=[
@ -31,15 +47,163 @@ class Migration(migrations.Migration):
"db_table": "billing_gateway", "db_table": "billing_gateway",
}, },
), ),
migrations.CreateModel(
name="Plan",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("slug", models.SlugField(max_length=100)),
("name", models.CharField(max_length=200)),
("price_amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")),
("price_currency", models.CharField(default="USD", max_length=3)),
(
"interval",
models.CharField(
choices=infrasynth.shared.enums.PlanInterval.choices,
default=infrasynth.shared.enums.PlanInterval["MONTHLY"],
max_length=20,
),
),
("trial_days", models.PositiveIntegerField(default=0)),
("features", models.JSONField(default=dict)),
("limits", models.JSONField(default=dict)),
("is_active", models.BooleanField(default=True)),
("external_id", models.CharField(blank=True, max_length=200)),
(
"app",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="plans", to="infrasynth_billing.app"
),
),
(
"gateway",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.paymentgateway",
),
),
],
options={
"db_table": "billing_plan",
},
),
migrations.CreateModel(
name="Entitlement",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"status",
models.CharField(
choices=infrasynth.shared.enums.EntitlementStatus.choices,
default=infrasynth.shared.enums.EntitlementStatus["ACTIVE"],
max_length=20,
),
),
("started_at", models.DateTimeField(auto_now_add=True)),
("current_period_end", models.DateTimeField(blank=True, null=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
("cancel_at_period_end", models.BooleanField(default=False)),
("source", models.CharField(default="manual", max_length=20)),
("metadata", models.JSONField(default=dict)),
(
"app",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="entitlements",
to="infrasynth_billing.app",
),
),
(
"tenant",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
(
"plan",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.plan",
),
),
],
options={
"db_table": "billing_entitlement",
},
),
migrations.CreateModel(
name="Subscription",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("external_id", models.CharField(blank=True, max_length=255)),
("status", models.CharField(choices=infrasynth.shared.enums.SubscriptionStatus.choices, max_length=20)),
("current_period_start", models.DateTimeField(blank=True, null=True)),
("current_period_end", models.DateTimeField(blank=True, null=True)),
("cancel_at_period_end", models.BooleanField(default=False)),
("cancelled_at", models.DateTimeField(blank=True, null=True)),
("trial_end", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(default=dict)),
(
"entitlement",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="subscriptions",
to="infrasynth_billing.entitlement",
),
),
(
"gateway",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.paymentgateway",
),
),
(
"plan",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.plan",
),
),
(
"tenant",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
],
options={
"db_table": "billing_subscription",
},
),
migrations.CreateModel( migrations.CreateModel(
name="Invoice", name="Invoice",
fields=[ fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("external_id", models.CharField(blank=True, max_length=255)), ("external_id", models.CharField(blank=True, max_length=255)),
("invoice_number", models.CharField(max_length=100, unique=True)), ("invoice_number", models.CharField(max_length=100)),
("amount", models.DecimalField(decimal_places=2, max_digits=12)), ("amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")),
("currency", models.CharField(default="USD", max_length=3)), ("currency", models.CharField(default="USD", max_length=3)),
("tax_amount", models.DecimalField(decimal_places=2, default=0, max_digits=12)), ("tax_amount", models.BigIntegerField(default=0)),
("tax_name", models.CharField(blank=True, max_length=100)), ("tax_name", models.CharField(blank=True, max_length=100)),
("status", models.CharField(choices=infrasynth.shared.enums.InvoiceStatus.choices, max_length=20)), ("status", models.CharField(choices=infrasynth.shared.enums.InvoiceStatus.choices, max_length=20)),
("due_date", models.DateTimeField(blank=True, null=True)), ("due_date", models.DateTimeField(blank=True, null=True)),
@ -57,9 +221,12 @@ class Migration(migrations.Migration):
), ),
), ),
( (
"user", "tenant",
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL editable=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
), ),
), ),
( (
@ -72,46 +239,27 @@ class Migration(migrations.Migration):
to="infrasynth_billing.paymentgateway", to="infrasynth_billing.paymentgateway",
), ),
), ),
(
"subscription",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="invoices",
to="infrasynth_billing.subscription",
),
),
], ],
options={ options={
"db_table": "billing_invoice", "db_table": "billing_invoice",
}, },
), ),
migrations.CreateModel(
name="BillingPlan",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("slug", models.SlugField(max_length=100, unique=True)),
("name", models.CharField(max_length=200)),
("description", models.TextField(blank=True)),
("price_amount", models.DecimalField(decimal_places=2, max_digits=12)),
("price_currency", models.CharField(default="USD", max_length=3)),
("interval", models.CharField(choices=infrasynth.shared.enums.BillingInterval.choices, max_length=20)),
("trial_days", models.IntegerField(default=0)),
("features", models.JSONField(default=list)),
("is_active", models.BooleanField(default=True)),
("external_id", models.CharField(blank=True, max_length=255)),
(
"gateway",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.paymentgateway",
),
),
],
options={
"db_table": "billing_plan",
},
),
migrations.CreateModel( migrations.CreateModel(
name="PaymentTransaction", name="PaymentTransaction",
fields=[ fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("external_id", models.CharField(blank=True, max_length=255)), ("external_id", models.CharField(blank=True, max_length=255)),
("amount", models.DecimalField(decimal_places=2, max_digits=12)), ("amount", models.BigIntegerField(help_text="Minor units (cents). Never a float.")),
("currency", models.CharField(default="USD", max_length=3)), ("currency", models.CharField(default="USD", max_length=3)),
("status", models.CharField(blank=True, max_length=50)), ("status", models.CharField(blank=True, max_length=50)),
("payment_method", models.CharField(blank=True, max_length=100)), ("payment_method", models.CharField(blank=True, max_length=100)),
@ -133,67 +281,41 @@ class Migration(migrations.Migration):
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.SET_NULL,
related_name="+", related_name="transactions",
to="infrasynth_billing.invoice", to="infrasynth_billing.invoice",
), ),
), ),
(
"tenant",
models.ForeignKey(
editable=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="tenancy.tenant",
),
),
], ],
options={ options={
"db_table": "billing_transaction", "db_table": "billing_transaction",
"indexes": [models.Index(fields=["tenant_id", "status"], name="billing_tra_tenant__7efcbc_idx")],
}, },
), ),
migrations.CreateModel( migrations.AddConstraint(
name="Subscription", model_name="plan",
fields=[ constraint=models.UniqueConstraint(fields=("app", "slug"), name="uniq_plan_slug_per_app"),
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("external_id", models.CharField(blank=True, max_length=255)),
("status", models.CharField(choices=infrasynth.shared.enums.SubscriptionStatus.choices, max_length=20)),
("current_period_start", models.DateTimeField(blank=True, null=True)),
("current_period_end", models.DateTimeField(blank=True, null=True)),
("cancel_at_period_end", models.BooleanField(default=False)),
("cancelled_at", models.DateTimeField(blank=True, null=True)),
("trial_end", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(default=dict)),
(
"gateway",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.paymentgateway",
),
),
(
"plan",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.billingplan",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL
),
),
],
options={
"db_table": "billing_subscription",
},
), ),
migrations.AddField( migrations.AddConstraint(
model_name="entitlement",
constraint=models.UniqueConstraint(fields=("tenant", "app"), name="uniq_tenant_app_entitlement"),
),
migrations.AddIndex(
model_name="subscription",
index=models.Index(fields=["tenant_id", "status"], name="billing_sub_tenant__d36cf8_idx"),
),
migrations.AddConstraint(
model_name="invoice", model_name="invoice",
name="subscription", constraint=models.UniqueConstraint(
field=models.ForeignKey( fields=("tenant", "invoice_number"), name="uniq_invoice_number_per_tenant"
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="infrasynth_billing.subscription",
), ),
), ),
] ]

View file

@ -1,10 +1,87 @@
from django.conf import settings
from django.db import models from django.db import models
from infrasynth.shared.enums import BillingInterval, InvoiceStatus, SubscriptionStatus from infrasynth.shared.enums import (
EntitlementStatus,
InvoiceStatus,
MonetizationModel,
PlanInterval,
SubscriptionStatus,
)
from infrasynth.tenancy.mixins import TenantOwnedModel
class App(models.Model):
"""A deployed InfraSynth app in the catalog (global)."""
slug = models.SlugField(max_length=100, unique=True)
name = models.CharField(max_length=200)
monetization = models.CharField(max_length=20, choices=MonetizationModel.choices)
is_active = models.BooleanField(default=True)
metadata = models.JSONField(default=dict)
class Meta:
db_table = "billing_app"
def __str__(self):
return self.name
class Plan(models.Model):
"""A purchasable tier of an app (global). Money is integer minor units."""
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="plans")
slug = models.SlugField(max_length=100)
name = models.CharField(max_length=200)
price_amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.")
price_currency = models.CharField(max_length=3, default="USD")
interval = models.CharField(max_length=20, choices=PlanInterval.choices, default=PlanInterval.MONTHLY)
trial_days = models.PositiveIntegerField(default=0)
features = models.JSONField(default=dict)
limits = models.JSONField(default=dict)
is_active = models.BooleanField(default=True)
gateway = models.ForeignKey(
"PaymentGateway",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="+",
)
external_id = models.CharField(max_length=200, blank=True)
class Meta:
db_table = "billing_plan"
constraints = [models.UniqueConstraint(fields=["app", "slug"], name="uniq_plan_slug_per_app")]
def __str__(self):
return f"{self.app.slug}:{self.slug}"
class Entitlement(TenantOwnedModel):
"""A tenant's right to use an app under a plan. The unit of enforcement."""
app = models.ForeignKey(App, on_delete=models.CASCADE, related_name="entitlements")
plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
status = models.CharField(max_length=20, choices=EntitlementStatus.choices, default=EntitlementStatus.ACTIVE)
started_at = models.DateTimeField(auto_now_add=True)
current_period_end = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
cancel_at_period_end = models.BooleanField(default=False)
source = models.CharField(max_length=20, default="manual")
metadata = models.JSONField(default=dict)
class Meta:
db_table = "billing_entitlement"
constraints = [
models.UniqueConstraint(fields=["tenant", "app"], name="uniq_tenant_app_entitlement"),
]
def __str__(self):
return f"{self.tenant_id}:{self.app.slug}:{self.status}"
class PaymentGateway(models.Model): class PaymentGateway(models.Model):
"""Platform payment provider account (global). Credentials are Fernet-encrypted."""
slug = models.SlugField(max_length=100, primary_key=True) slug = models.SlugField(max_length=100, primary_key=True)
display_name = models.CharField(max_length=200) display_name = models.CharField(max_length=200)
gateway_class = models.CharField(max_length=500) gateway_class = models.CharField(max_length=500)
@ -20,40 +97,18 @@ class PaymentGateway(models.Model):
return self.display_name return self.display_name
class BillingPlan(models.Model): class Subscription(TenantOwnedModel):
slug = models.SlugField(max_length=100, unique=True) """An active subscription backing an entitlement (tenant-owned)."""
name = models.CharField(max_length=200)
description = models.TextField(blank=True) entitlement = models.ForeignKey(
price_amount = models.DecimalField(max_digits=12, decimal_places=2) Entitlement,
price_currency = models.CharField(max_length=3, default="USD")
interval = models.CharField(max_length=20, choices=BillingInterval.choices)
trial_days = models.IntegerField(default=0)
features = models.JSONField(default=list)
is_active = models.BooleanField(default=True)
gateway = models.ForeignKey(
PaymentGateway,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name="+", related_name="subscriptions",
)
external_id = models.CharField(max_length=255, blank=True)
class Meta:
db_table = "billing_plan"
def __str__(self):
return self.name
class Subscription(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
) )
plan = models.ForeignKey( plan = models.ForeignKey(
BillingPlan, Plan,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
@ -77,23 +132,21 @@ class Subscription(models.Model):
class Meta: class Meta:
db_table = "billing_subscription" db_table = "billing_subscription"
indexes = [models.Index(fields=["tenant_id", "status"])]
def __str__(self): def __str__(self):
return f"{self.user}#{self.plan}" return f"{self.tenant_id}#{self.plan}"
class Invoice(models.Model): class Invoice(TenantOwnedModel):
"""A generated invoice (tenant-owned). Money is integer minor units."""
subscription = models.ForeignKey( subscription = models.ForeignKey(
Subscription, Subscription,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name="+", related_name="invoices",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="+",
) )
gateway = models.ForeignKey( gateway = models.ForeignKey(
PaymentGateway, PaymentGateway,
@ -103,10 +156,10 @@ class Invoice(models.Model):
related_name="+", related_name="+",
) )
external_id = models.CharField(max_length=255, blank=True) external_id = models.CharField(max_length=255, blank=True)
invoice_number = models.CharField(max_length=100, unique=True) invoice_number = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2) amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.")
currency = models.CharField(max_length=3, default="USD") currency = models.CharField(max_length=3, default="USD")
tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) tax_amount = models.BigIntegerField(default=0)
tax_name = models.CharField(max_length=100, blank=True) tax_name = models.CharField(max_length=100, blank=True)
status = models.CharField(max_length=20, choices=InvoiceStatus.choices) status = models.CharField(max_length=20, choices=InvoiceStatus.choices)
due_date = models.DateTimeField(null=True, blank=True) due_date = models.DateTimeField(null=True, blank=True)
@ -123,18 +176,23 @@ class Invoice(models.Model):
class Meta: class Meta:
db_table = "billing_invoice" db_table = "billing_invoice"
constraints = [
models.UniqueConstraint(fields=["tenant", "invoice_number"], name="uniq_invoice_number_per_tenant"),
]
def __str__(self): def __str__(self):
return self.invoice_number return self.invoice_number
class PaymentTransaction(models.Model): class PaymentTransaction(TenantOwnedModel):
"""A single payment attempt (tenant-owned)."""
invoice = models.ForeignKey( invoice = models.ForeignKey(
Invoice, Invoice,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name="+", related_name="transactions",
) )
gateway = models.ForeignKey( gateway = models.ForeignKey(
PaymentGateway, PaymentGateway,
@ -144,7 +202,7 @@ class PaymentTransaction(models.Model):
related_name="+", related_name="+",
) )
external_id = models.CharField(max_length=255, blank=True) external_id = models.CharField(max_length=255, blank=True)
amount = models.DecimalField(max_digits=12, decimal_places=2) amount = models.BigIntegerField(help_text="Minor units (cents). Never a float.")
currency = models.CharField(max_length=3, default="USD") currency = models.CharField(max_length=3, default="USD")
status = models.CharField(max_length=50, blank=True) status = models.CharField(max_length=50, blank=True)
payment_method = models.CharField(max_length=100, blank=True) payment_method = models.CharField(max_length=100, blank=True)
@ -153,6 +211,7 @@ class PaymentTransaction(models.Model):
class Meta: class Meta:
db_table = "billing_transaction" db_table = "billing_transaction"
indexes = [models.Index(fields=["tenant_id", "status"])]
def __str__(self): def __str__(self):
return f"txn_{self.pk}" return f"txn_{self.pk}"

View file

@ -1,14 +1,88 @@
from rest_framework import serializers from rest_framework import serializers
from .models import ( from .models import (
BillingPlan, App,
Entitlement,
Invoice, Invoice,
PaymentGateway, PaymentGateway,
PaymentTransaction, PaymentTransaction,
Plan,
Subscription, Subscription,
) )
class AppSerializer(serializers.ModelSerializer):
class Meta:
model = App
fields = ["id", "slug", "name", "monetization", "is_active", "metadata"]
read_only_fields = ["id"]
class PlanSerializer(serializers.ModelSerializer):
app_info = serializers.SerializerMethodField()
gateway_info = serializers.SerializerMethodField()
class Meta:
model = Plan
fields = [
"id",
"app",
"app_info",
"slug",
"name",
"price_amount",
"price_currency",
"interval",
"trial_days",
"features",
"limits",
"is_active",
"gateway",
"gateway_info",
"external_id",
]
read_only_fields = ["id"]
def get_app_info(self, obj):
return {"id": obj.app_id, "slug": obj.app.slug, "name": obj.app.name}
def get_gateway_info(self, obj):
if obj.gateway_id:
return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name}
return None
class EntitlementSerializer(serializers.ModelSerializer):
app_info = serializers.SerializerMethodField()
plan_info = serializers.SerializerMethodField()
class Meta:
model = Entitlement
fields = [
"id",
"app",
"app_info",
"plan",
"plan_info",
"status",
"started_at",
"current_period_end",
"expires_at",
"cancel_at_period_end",
"source",
"metadata",
]
read_only_fields = ["id", "started_at"]
def get_app_info(self, obj):
return {"slug": obj.app.slug, "name": obj.app.name}
def get_plan_info(self, obj):
if obj.plan_id:
return {"id": obj.plan_id, "slug": obj.plan.slug, "name": obj.plan.name}
return None
class PaymentGatewaySerializer(serializers.ModelSerializer): class PaymentGatewaySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = PaymentGateway model = PaymentGateway
@ -24,45 +98,17 @@ class PaymentGatewaySerializer(serializers.ModelSerializer):
read_only_fields = ["slug"] read_only_fields = ["slug"]
class BillingPlanSerializer(serializers.ModelSerializer):
gateway_info = serializers.SerializerMethodField()
class Meta:
model = BillingPlan
fields = [
"id",
"slug",
"name",
"description",
"price_amount",
"price_currency",
"interval",
"trial_days",
"features",
"is_active",
"gateway",
"gateway_info",
"external_id",
]
read_only_fields = ["id"]
def get_gateway_info(self, obj):
if obj.gateway_id:
return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name}
return None
class SubscriptionSerializer(serializers.ModelSerializer): class SubscriptionSerializer(serializers.ModelSerializer):
plan_info = serializers.SerializerMethodField() plan_info = serializers.SerializerMethodField()
gateway_info = serializers.SerializerMethodField() gateway_info = serializers.SerializerMethodField()
user_info = serializers.SerializerMethodField() entitlement_info = serializers.SerializerMethodField()
class Meta: class Meta:
model = Subscription model = Subscription
fields = [ fields = [
"id", "id",
"user", "entitlement",
"user_info", "entitlement_info",
"plan", "plan",
"plan_info", "plan_info",
"gateway", "gateway",
@ -88,14 +134,15 @@ class SubscriptionSerializer(serializers.ModelSerializer):
return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name}
return None return None
def get_user_info(self, obj): def get_entitlement_info(self, obj):
return {"id": obj.user_id, "display": str(obj.user)} if obj.entitlement_id:
return {"id": obj.entitlement_id, "status": obj.entitlement.status}
return None
class InvoiceSerializer(serializers.ModelSerializer): class InvoiceSerializer(serializers.ModelSerializer):
subscription_info = serializers.SerializerMethodField() subscription_info = serializers.SerializerMethodField()
gateway_info = serializers.SerializerMethodField() gateway_info = serializers.SerializerMethodField()
user_info = serializers.SerializerMethodField()
pdf_file_info = serializers.SerializerMethodField() pdf_file_info = serializers.SerializerMethodField()
class Meta: class Meta:
@ -104,8 +151,6 @@ class InvoiceSerializer(serializers.ModelSerializer):
"id", "id",
"subscription", "subscription",
"subscription_info", "subscription_info",
"user",
"user_info",
"gateway", "gateway",
"gateway_info", "gateway_info",
"external_id", "external_id",
@ -134,9 +179,6 @@ class InvoiceSerializer(serializers.ModelSerializer):
return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name}
return None return None
def get_user_info(self, obj):
return {"id": obj.user_id, "display": str(obj.user)}
def get_pdf_file_info(self, obj): def get_pdf_file_info(self, obj):
if obj.pdf_file_id: if obj.pdf_file_id:
return {"id": obj.pdf_file_id, "filename": obj.pdf_file.original_filename} return {"id": obj.pdf_file_id, "filename": obj.pdf_file.original_filename}
@ -179,6 +221,15 @@ class PaymentTransactionSerializer(serializers.ModelSerializer):
return None return None
class CheckoutSerializer(serializers.Serializer):
"""``POST /billing/checkout/`` — tenant comes from the token, never the body."""
app = serializers.SlugField()
plan = serializers.SlugField()
success_url = serializers.URLField(required=False)
cancel_url = serializers.URLField(required=False)
class SubscribeSerializer(serializers.Serializer): class SubscribeSerializer(serializers.Serializer):
plan_slug = serializers.SlugField() plan_slug = serializers.SlugField()
success_url = serializers.URLField(required=False) success_url = serializers.URLField(required=False)

View file

@ -1,15 +1,20 @@
"""Billing service: checkouts, subscriptions, invoices (``ENTITLEMENTS.md``)."""
from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus from infrasynth.shared.enums import EntitlementStatus, InvoiceStatus, SubscriptionStatus
from infrasynth.shared.settings_utils import get_setting from infrasynth.shared.settings_utils import get_setting
from .models import BillingPlan, Invoice, PaymentGateway, Subscription from .entitlements import EntitlementService
from .models import App, Entitlement, Invoice, PaymentGateway, PaymentTransaction, Plan, Subscription
from .signals import ( from .signals import (
entitlement_changed,
invoice_generated, invoice_generated,
subscription_cancelled, subscription_cancelled,
subscription_created, subscription_created,
@ -17,46 +22,48 @@ from .signals import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["BillingService", "EntitlementService"]
class BillingService: class BillingService:
"""Public API for subscriptions, checkouts, and invoices.""" """Public API for plans, checkouts, subscriptions, and invoices."""
def create_checkout_session( def create_checkout_session(
self, self,
plan_slug: str, plan: Plan,
user, tenant,
user=None,
success_url: str | None = None, success_url: str | None = None,
cancel_url: str | None = None, cancel_url: str | None = None,
): ):
"""Creates a checkout session in the plan's payment gateway.""" """Creates a checkout session in the plan's payment gateway."""
plan = BillingPlan.objects.select_related("gateway").get(slug=plan_slug, is_active=True)
gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first() gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first()
if gateway is None: if gateway is None:
raise ValueError("No active payment gateway configured for this plan.") raise ValueError("No active payment gateway configured for this plan.")
gateway_instance = self._get_gateway(gateway) gateway_instance = self._get_gateway(gateway)
result = gateway_instance.create_checkout_session( return gateway_instance.create_checkout_session(
plan, plan,
user, user,
tenant=tenant,
success_url=success_url, success_url=success_url,
cancel_url=cancel_url, cancel_url=cancel_url,
) ), gateway
return result, gateway
def create_subscription( def create_subscription(
self, self,
plan: BillingPlan, plan: Plan,
user, tenant,
gateway: PaymentGateway, gateway: PaymentGateway | None,
external_id: str = "", external_id: str = "",
) -> Subscription: ) -> Subscription:
"""Creates a subscription record after checkout confirmation.""" """Creates a subscription record (and its entitlement) after checkout."""
now = timezone.now() now = timezone.now()
interval_days = 365 if plan.interval == "yearly" else 30 interval_days = 365 if plan.interval == "yearly" else 30
trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None
subscription = Subscription.objects.create( subscription = Subscription.all_objects.create(
user=user, tenant=tenant,
plan=plan, plan=plan,
gateway=gateway, gateway=gateway,
external_id=external_id, external_id=external_id,
@ -66,17 +73,41 @@ class BillingService:
trial_end=trial_end, trial_end=trial_end,
) )
entitlement, _ = Entitlement.all_objects.get_or_create(
tenant=tenant,
app=plan.app,
defaults={
"plan": plan,
"status": EntitlementStatus.TRIALING if trial_end else EntitlementStatus.ACTIVE,
"current_period_end": subscription.current_period_end,
"source": gateway.slug if gateway else "manual",
},
)
entitlement.plan = plan
entitlement.status = EntitlementStatus.TRIALING if trial_end and trial_end > now else EntitlementStatus.ACTIVE
entitlement.current_period_end = subscription.current_period_end
entitlement.save()
subscription.entitlement = entitlement
subscription.save(update_fields=["entitlement"])
EntitlementService().invalidate(tenant, plan.app.slug)
entitlement_changed.send(
sender=Entitlement,
tenant_id=str(tenant.pk),
app=plan.app.slug,
status=entitlement.status,
)
subscription_created.send( subscription_created.send(
sender=Subscription, sender=Subscription,
user=user, tenant_id=str(tenant.pk),
plan_slug=plan.slug, plan_slug=plan.slug,
app_slug=plan.app.slug,
gateway=gateway.slug if gateway else "", gateway=gateway.slug if gateway else "",
external_id=external_id, external_id=external_id,
) )
return subscription return subscription
def cancel_subscription(self, subscription: Subscription) -> bool: def cancel_subscription(self, subscription: Subscription) -> bool:
"""Cancels a subscription in the gateway and locally."""
gateway = subscription.gateway gateway = subscription.gateway
if gateway: if gateway:
try: try:
@ -90,16 +121,22 @@ class BillingService:
subscription.cancel_at_period_end = False subscription.cancel_at_period_end = False
subscription.save() subscription.save()
if subscription.entitlement_id:
entitlement = subscription.entitlement
if entitlement is not None:
entitlement.status = EntitlementStatus.CANCELLED
entitlement.save(update_fields=["status"])
EntitlementService().invalidate(subscription.tenant, entitlement.app.slug)
subscription_cancelled.send( subscription_cancelled.send(
sender=Subscription, sender=Subscription,
user=subscription.user, tenant_id=str(subscription.tenant_id),
plan_slug=subscription.plan.slug if subscription.plan else "", plan_slug=subscription.plan.slug if subscription.plan else "",
reason="user_requested", reason="user_requested",
) )
return True return True
def sync_subscription(self, subscription: Subscription) -> dict: def sync_subscription(self, subscription: Subscription) -> dict:
"""Syncs subscription state from the gateway into the local record."""
gateway = subscription.gateway gateway = subscription.gateway
if gateway is None: if gateway is None:
return {} return {}
@ -122,25 +159,25 @@ class BillingService:
def generate_invoice( def generate_invoice(
self, self,
subscription: Subscription, subscription: Subscription,
amount=None, amount: int | None = None,
line_items: list | None = None, line_items: list | None = None,
) -> Invoice: ) -> Invoice:
"""Creates an invoice record and triggers PDF generation.""" """Creates an invoice (minor units) and triggers PDF generation."""
plan = subscription.plan plan = subscription.plan
currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD") currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD")
if amount is None: if amount is None:
amount = plan.price_amount if plan else 0 amount = plan.price_amount if plan else 0
amount = Decimal(str(amount)) amount = int(amount)
tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0) tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0)
tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "") tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "")
tax_amount = amount * (Decimal(tax_percentage) / Decimal(100)) tax_amount = int(amount * (int(tax_percentage) / 100))
invoice = Invoice.objects.create( invoice = Invoice.all_objects.create(
tenant=subscription.tenant,
subscription=subscription, subscription=subscription,
user=subscription.user,
gateway=subscription.gateway, gateway=subscription.gateway,
invoice_number=self._next_invoice_number(), invoice_number=self._next_invoice_number(subscription.tenant),
amount=amount, amount=amount,
currency=currency, currency=currency,
tax_amount=tax_amount, tax_amount=tax_amount,
@ -151,7 +188,7 @@ class BillingService:
or [ or [
{ {
"description": plan.name if plan else "Subscription", "description": plan.name if plan else "Subscription",
"amount": float(amount), "amount": amount,
"quantity": 1, "quantity": 1,
} }
], ],
@ -159,23 +196,189 @@ class BillingService:
invoice_generated.send( invoice_generated.send(
sender=Invoice, sender=Invoice,
user=subscription.user, tenant_id=str(subscription.tenant_id),
invoice_id=invoice.id, invoice_id=invoice.id,
amount=float(invoice.amount), amount=invoice.amount,
) )
from .invoice_generator import generate_invoice_pdf from .invoice_generator import generate_invoice_pdf
generate_invoice_pdf.delay(invoice.id) generate_invoice_pdf.delay(invoice.id, str(subscription.tenant_id))
return invoice return invoice
def _next_invoice_number(self) -> str: # --- entitlement lifecycle (ENTITLEMENTS.md §5) -------------------------
def mark_past_due(self, entitlement: Entitlement) -> None:
entitlement.status = EntitlementStatus.PAST_DUE
metadata = dict(entitlement.metadata or {})
metadata.setdefault("past_due_since", timezone.now().isoformat())
entitlement.metadata = metadata
entitlement.save(update_fields=["status", "metadata"])
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
def enter_grace(self, entitlement: Entitlement) -> None:
entitlement.status = EntitlementStatus.GRACE
entitlement.save(update_fields=["status"])
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
def suspend_entitlement(self, entitlement: Entitlement) -> None:
entitlement.status = EntitlementStatus.SUSPENDED
entitlement.save(update_fields=["status"])
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
def reinstate_entitlement(self, entitlement: Entitlement) -> None:
entitlement.status = EntitlementStatus.ACTIVE
entitlement.save(update_fields=["status"])
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
# --- helpers ------------------------------------------------------------
def _next_invoice_number(self, tenant) -> str:
prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-") prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-")
year = timezone.now().year year = timezone.now().year
full_prefix = f"{prefix}{year}-" full_prefix = f"{prefix}{year}-"
count = Invoice.objects.filter(invoice_number__startswith=full_prefix).count() count = Invoice.all_objects.filter(tenant=tenant, invoice_number__startswith=full_prefix).count()
return f"{full_prefix}{count + 1:06d}" return f"{full_prefix}{count + 1:06d}"
def _get_gateway(self, gateway: PaymentGateway): def _get_gateway(self, gateway: PaymentGateway):
gateway_cls = import_string(gateway.gateway_class) gateway_cls = import_string(gateway.gateway_class)
return gateway_cls(gateway.config) return gateway_cls(gateway.config)
# --- inbound webhooks (ENTITLEMENTS.md §6) ------------------------------
def process_webhook_event(self, gateway, event_type: str, data: dict, *, event_id: str | None = None) -> dict:
"""Idempotently applies a verified provider event to billing state.
Returns a small result dict describing what happened. Provider payloads
differ, so this extracts the common fields heuristically; a provider can
pre-normalize in its ``handle_webhook``.
"""
from django.core.cache import cache
from infrasynth.tenancy.models import Tenant
from .signals import payment_failed, payment_succeeded
data = data if isinstance(data, dict) else {}
tenant_id = self._extract_tenant_id(data)
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None
if event_id:
idem_key = f"tenant:{tenant_id or 'unknown'}:billing:webhook:{event_id}"
if cache.get(idem_key):
return {"status": "duplicate", "event_id": event_id}
cache.set(idem_key, True, 60 * 60 * 24)
lower = event_type.lower()
is_paid = any(token in lower for token in ("paid", "succeeded", "approved", "activated"))
is_failed = any(token in lower for token in ("failed", "declined", "past_due", "rejected"))
subscription = self._find_subscription(data, tenant)
entitlement = subscription.entitlement if subscription and subscription.entitlement_id else None
if entitlement is None and tenant is not None:
entitlement = Entitlement.all_objects.filter(tenant=tenant).order_by("-id").first()
invoice = self._find_invoice(data, tenant)
amount, currency = self._extract_amount(data)
transaction = None
if tenant is not None:
transaction = PaymentTransaction.all_objects.create(
tenant=tenant,
gateway=gateway,
invoice=invoice,
external_id=str(data.get("id") or data.get("payment_id") or ""),
amount=amount,
currency=currency,
status=event_type,
metadata=data,
)
result: dict = {"status": "processed", "event_type": event_type}
if is_paid and entitlement is not None:
entitlement.status = EntitlementStatus.ACTIVE
entitlement.metadata = {**(entitlement.metadata or {}), "failed_payments": 0}
entitlement.save(update_fields=["status", "metadata"])
EntitlementService().invalidate(tenant, entitlement.app.slug)
if tenant is not None and tenant.status != Tenant.Status.ACTIVE:
tenant.status = Tenant.Status.ACTIVE
tenant.suspended_at = None
tenant.save(update_fields=["status", "suspended_at"])
if invoice is not None and invoice.status != InvoiceStatus.PAID:
invoice.status = InvoiceStatus.PAID
invoice.paid_at = timezone.now()
invoice.save(update_fields=["status", "paid_at"])
payment_succeeded.send(
sender=PaymentTransaction,
tenant_id=str(tenant.pk) if tenant else None,
invoice_id=invoice.id if invoice else None,
amount=amount,
)
result["action"] = "reinstated"
elif is_failed and entitlement is not None:
metadata = dict(entitlement.metadata or {})
metadata["failed_payments"] = int(metadata.get("failed_payments", 0)) + 1
entitlement.status = EntitlementStatus.PAST_DUE
entitlement.metadata = metadata
entitlement.save(update_fields=["status", "metadata"])
EntitlementService().invalidate(tenant, entitlement.app.slug)
payment_failed.send(
sender=PaymentTransaction,
tenant_id=str(tenant.pk) if tenant else None,
invoice_id=invoice.id if invoice else None,
error=event_type,
)
result["action"] = "past_due"
if transaction is not None:
result["transaction_id"] = transaction.id
return result
@staticmethod
def _extract_tenant_id(data: dict):
metadata = data.get("metadata") or {}
if isinstance(metadata, dict) and metadata.get("tenant_id"):
return metadata["tenant_id"]
return data.get("tenant_id") or data.get("tenantId")
@staticmethod
def _find_subscription(data: dict, tenant):
external_id = data.get("subscription") or data.get("preapproval_id") or data.get("subscription_id")
if external_id:
found = Subscription.all_objects.filter(external_id=str(external_id)).first()
if found is not None:
return found
if tenant is not None:
return Subscription.all_objects.filter(tenant=tenant).order_by("-id").first()
return None
@staticmethod
def _find_invoice(data: dict, tenant):
external_id = data.get("invoice") or data.get("invoice_id")
if external_id:
found = Invoice.all_objects.filter(external_id=str(external_id)).first()
if found is not None:
return found
if tenant is not None:
return Invoice.all_objects.filter(tenant=tenant, status=InvoiceStatus.OPEN).order_by("-id").first()
return None
@staticmethod
def _extract_amount(data: dict) -> tuple[int, str]:
raw = (
data.get("amount")
or data.get("amount_paid")
or data.get("amount_in_cents")
or data.get("transaction_amount")
or 0
)
try:
amount = int(raw)
except (TypeError, ValueError):
amount = 0
currency = str(data.get("currency") or data.get("currency_id") or "USD").upper()
return amount, currency
def get_app(app_slug: str) -> App:
return App.objects.get(slug=app_slug, is_active=True)

View file

@ -1,9 +1,15 @@
"""Billing signals.
Every signal carries ``tenant_id`` explicitly (``TENANCY.md`` §7).
"""
from django.dispatch import Signal from django.dispatch import Signal
subscription_created = Signal() subscription_created = Signal() # kwargs: tenant_id, plan_slug, app_slug, gateway, external_id
subscription_cancelled = Signal() subscription_cancelled = Signal() # kwargs: tenant_id, plan_slug, reason
subscription_renewed = Signal() subscription_renewed = Signal() # kwargs: tenant_id, subscription_id
payment_succeeded = Signal() payment_succeeded = Signal() # kwargs: tenant_id, invoice_id, amount
payment_failed = Signal() payment_failed = Signal() # kwargs: tenant_id, invoice_id, error
invoice_generated = Signal() invoice_generated = Signal() # kwargs: tenant_id, invoice_id, amount
invoice_paid = Signal() invoice_paid = Signal() # kwargs: tenant_id, invoice_id, amount
entitlement_changed = Signal() # kwargs: tenant_id, app, status

156
infrasynth/billing/tasks.py Normal file
View file

@ -0,0 +1,156 @@
"""Scheduled billing lifecycle jobs (``ENTITLEMENTS.md`` §5).
All tasks iterate tenants explicitly and bind ``current_tenant`` per row; none
runs "globally" against tenant-owned tables (``TENANCY.md`` §7).
"""
from __future__ import annotations
import logging
from datetime import timedelta
from celery import shared_task
from django.utils import timezone
from infrasynth.shared.enums import EntitlementStatus, SubscriptionStatus
from infrasynth.shared.settings_utils import get_setting
from infrasynth.tenancy.context import tenant_context
logger = logging.getLogger(__name__)
def _with_tenant(tenant):
return tenant_context(tenant)
@shared_task(name="infrasynth.billing.sync_subscriptions")
def sync_subscriptions():
"""Pulls the latest state from each active gateway subscription."""
from .models import Subscription
from .services import BillingService
service = BillingService()
synced = 0
subs = Subscription.all_objects.filter(
status__in=[
SubscriptionStatus.ACTIVE,
SubscriptionStatus.PAST_DUE,
SubscriptionStatus.TRIALING,
],
gateway__isnull=False,
).select_related("gateway", "entitlement")
for sub in subs:
try:
with _with_tenant(sub.tenant):
service.sync_subscription(sub)
synced += 1
except Exception: # noqa: BLE001
logger.exception("Failed to sync subscription %s", sub.pk)
return synced
@shared_task(name="infrasynth.billing.advance_entitlement_lifecycle")
def advance_entitlement_lifecycle():
"""Moves past-due entitlements to suspended once the grace window elapses."""
from infrasynth.tenancy.models import Tenant
from .models import Entitlement
from .services import BillingService
grace_days = int(get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5))
max_retries = int(get_setting("INFRASYNTH_BILLING", "MAX_RETRY_FAILED_PAYMENTS", 3))
service = BillingService()
suspended = 0
candidates = Entitlement.all_objects.filter(
status__in=[EntitlementStatus.PAST_DUE, EntitlementStatus.GRACE]
).select_related("tenant", "app")
now = timezone.now()
for entitlement in candidates:
metadata = dict(entitlement.metadata or {})
since_raw = metadata.get("past_due_since")
if not since_raw:
metadata["past_due_since"] = now.isoformat()
entitlement.metadata = metadata
entitlement.save(update_fields=["metadata"])
continue
from datetime import datetime
try:
since = datetime.fromisoformat(since_raw)
except ValueError:
continue
attempts = int(metadata.get("failed_payments", 0))
if now - since < timedelta(days=grace_days) and attempts < max_retries:
continue
with _with_tenant(entitlement.tenant):
service.suspend_entitlement(entitlement)
tenant = entitlement.tenant
if tenant.status != Tenant.Status.SUSPENDED:
tenant.status = Tenant.Status.SUSPENDED
tenant.suspended_at = now
tenant.save(update_fields=["status", "suspended_at"])
suspended += 1
return suspended
@shared_task(name="infrasynth.billing.expire_entitlements")
def expire_entitlements():
"""Expires cancelled-at-period-end entitlements whose period has ended."""
from .models import Entitlement
from .services import EntitlementService
now = timezone.now()
expired = 0
candidates = Entitlement.all_objects.filter(
status__in=[EntitlementStatus.ACTIVE, EntitlementStatus.TRIALING, EntitlementStatus.CANCELLED]
).select_related("tenant", "app")
for entitlement in candidates:
if not entitlement.cancel_at_period_end:
continue
if entitlement.current_period_end and entitlement.current_period_end > now:
continue
entitlement.status = EntitlementStatus.EXPIRED
entitlement.save(update_fields=["status"])
with _with_tenant(entitlement.tenant):
EntitlementService().invalidate(entitlement.tenant, entitlement.app.slug)
expired += 1
return expired
@shared_task(name="infrasynth.billing.generate_renewal_invoices")
def generate_renewal_invoices():
"""Creates an open invoice ahead of each subscription renewal."""
from django.db.models import Q
from infrasynth.shared.enums import InvoiceStatus
from .models import Invoice, Subscription
from .services import BillingService
days_ahead = int(get_setting("INFRASYNTH_BILLING", "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL", 3))
now = timezone.now()
horizon = now + timedelta(days=days_ahead)
service = BillingService()
created = 0
subs = Subscription.all_objects.filter(
status=SubscriptionStatus.ACTIVE,
current_period_end__lte=horizon,
current_period_end__gt=now,
).select_related("tenant", "plan")
for sub in subs:
already = (
Invoice.all_objects.filter(
subscription=sub,
status__in=[InvoiceStatus.DRAFT, InvoiceStatus.OPEN],
)
.filter(Q(due_date__isnull=True) | Q(due_date__gte=now))
.exists()
)
if already:
continue
with _with_tenant(sub.tenant):
service.generate_invoice(sub)
created += 1
return created

View file

@ -2,26 +2,40 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
BillingPlanViewSet, AppViewSet,
EntitlementViewSet,
InvoiceViewSet, InvoiceViewSet,
PaymentGatewayViewSet, PaymentGatewayViewSet,
PaymentTransactionViewSet, PaymentTransactionViewSet,
PlanViewSet,
SubscriptionViewSet, SubscriptionViewSet,
WebhookViewSet, WebhookViewSet,
) )
router = DefaultRouter() router = DefaultRouter()
router.register(r"gateways", PaymentGatewayViewSet, basename="billing-gateways") router.register(r"gateways", PaymentGatewayViewSet, basename="billing-gateways")
router.register(r"plans", BillingPlanViewSet, basename="billing-plans") router.register(r"apps", AppViewSet, basename="billing-apps")
router.register(r"plans", PlanViewSet, basename="billing-plans")
router.register(r"entitlements", EntitlementViewSet, basename="billing-entitlements")
router.register(r"subscriptions", SubscriptionViewSet, basename="billing-subscriptions") router.register(r"subscriptions", SubscriptionViewSet, basename="billing-subscriptions")
router.register(r"invoices", InvoiceViewSet, basename="billing-invoices") router.register(r"invoices", InvoiceViewSet, basename="billing-invoices")
router.register(r"transactions", PaymentTransactionViewSet, basename="billing-transactions") router.register(r"transactions", PaymentTransactionViewSet, basename="billing-transactions")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),
path(
"checkout/",
SubscriptionViewSet.as_view({"post": "checkout"}),
name="billing-checkout",
),
path( path(
"webhook/receive/", "webhook/receive/",
WebhookViewSet.as_view({"post": "receive"}), WebhookViewSet.as_view({"post": "receive"}),
name="billing-webhook-receive", name="billing-webhook-receive",
), ),
path(
"webhook/<str:provider>/",
WebhookViewSet.as_view({"post": "receive"}),
name="billing-webhook-provider",
),
] ]

Some files were not shown because too many files have changed in this diff Show more