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
BIN
.coverage
3
.github/workflows/ci.yml
vendored
|
|
@ -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
|
|
@ -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/
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
88
AGENTS.md
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -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
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -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
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
11
infrasynth/api/__init__.py
Normal 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"]
|
||||||
117
infrasynth/api/exceptions.py
Normal 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
|
||||||
56
infrasynth/api/idempotency.py
Normal 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
|
||||||
40
infrasynth/api/middleware.py
Normal 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
|
||||||
34
infrasynth/api/pagination.py
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
58
infrasynth/api/renderers.py
Normal 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)
|
||||||
58
infrasynth/api/throttling.py
Normal 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
|
|
@ -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"),
|
||||||
|
]
|
||||||
34
infrasynth/api/webhooks.py
Normal 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}],
|
||||||
|
)
|
||||||
|
|
@ -9,6 +9,7 @@ from .models import APIInteractionLog
|
||||||
|
|
||||||
class AuditAPIMiddleware(MiddlewareMixin):
|
class AuditAPIMiddleware(MiddlewareMixin):
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
|
if not getattr(request, "request_id", None):
|
||||||
request.request_id = str(uuid.uuid4())
|
request.request_id = str(uuid.uuid4())
|
||||||
request._audit_start_time = time.time()
|
request._audit_start_time = time.time()
|
||||||
|
|
||||||
|
|
@ -64,7 +65,12 @@ 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
|
||||||
|
|
||||||
|
from infrasynth.tenancy.context import get_current_tenant
|
||||||
|
|
||||||
|
tenant = get_current_tenant()
|
||||||
|
try:
|
||||||
APIInteractionLog.objects.create(
|
APIInteractionLog.objects.create(
|
||||||
|
tenant=tenant,
|
||||||
method=request.method,
|
method=request.method,
|
||||||
path=path,
|
path=path,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
|
|
@ -76,5 +82,9 @@ class AuditAPIMiddleware(MiddlewareMixin):
|
||||||
request_id=getattr(request, "request_id", ""),
|
request_id=getattr(request, "request_id", ""),
|
||||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"tenant",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="+",
|
||||||
|
to="tenancy.tenant",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"db_table": "audit_api_interaction_log",
|
"db_table": "audit_api_interaction_log",
|
||||||
},
|
"indexes": [models.Index(fields=["tenant_id", "timestamp"], name="audit_api_i_tenant__e12b1e_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
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "audit_security_event",
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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")],
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
101
infrasynth/billing/entitlements.py
Normal 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))
|
||||||
|
|
@ -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"],
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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: ...
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ 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
|
||||||
|
|
||||||
|
tenant = Tenant.objects.filter(pk=tenant_id).first() if tenant_id else None
|
||||||
|
with tenant_context(tenant):
|
||||||
try:
|
try:
|
||||||
invoice = Invoice.objects.select_related("user", "subscription", "subscription__plan", "gateway").get(
|
invoice = Invoice.all_objects.select_related("subscription", "subscription__plan", "gateway").get(
|
||||||
pk=invoice_id
|
pk=invoice_id
|
||||||
)
|
)
|
||||||
except Invoice.DoesNotExist:
|
except Invoice.DoesNotExist:
|
||||||
|
|
@ -40,7 +45,6 @@ def generate_invoice_pdf(self, invoice_id):
|
||||||
stored = FileService().upload(
|
stored = FileService().upload(
|
||||||
content,
|
content,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
user=invoice.user,
|
|
||||||
metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number},
|
metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="entitlement",
|
||||||
|
constraint=models.UniqueConstraint(fields=("tenant", "app"), name="uniq_tenant_app_entitlement"),
|
||||||
),
|
),
|
||||||
(
|
migrations.AddIndex(
|
||||||
"plan",
|
model_name="subscription",
|
||||||
models.ForeignKey(
|
index=models.Index(fields=["tenant_id", "status"], name="billing_sub_tenant__d36cf8_idx"),
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
related_name="+",
|
|
||||||
to="infrasynth_billing.billingplan",
|
|
||||||
),
|
),
|
||||||
),
|
migrations.AddConstraint(
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"db_table": "billing_subscription",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
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",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||