Skip to content

Commit aff0156

Browse files
authored
Minor/v0.5.0 purchase invoices receipts (#20)
* feat: add Document base class for purchase invoices and receipts * feat: add PurchaseInvoice model * feat: add Receipt model * feat: export Document, PurchaseInvoice, and Receipt * test: add tests for PurchaseInvoice and Receipt * chore: bump version to 0.5.0 * chore: bump project version to 0.5.0 in uv.lock * refactor: remove section comments from document module and tests * feat: track and submit destroyed document details via _destroy attribute during save
1 parent 8ba3265 commit aff0156

9 files changed

Lines changed: 559 additions & 2 deletions

File tree

moneysnake/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@
2525
from .model import MoneybirdModel as MoneybirdModel
2626
from .model import Saveable as Saveable
2727
from .model import Synchronizable as Synchronizable
28+
from .document import Document as Document
29+
from .document import DocumentDetailsAttribute as DocumentDetailsAttribute
2830
from .payment import Payment as Payment
31+
from .purchase_invoice import PurchaseInvoice as PurchaseInvoice
32+
from .purchase_invoice import (
33+
PurchaseInvoiceDetailsAttribute as PurchaseInvoiceDetailsAttribute,
34+
)
35+
from .receipt import Receipt as Receipt
36+
from .receipt import ReceiptDetailsAttribute as ReceiptDetailsAttribute
2937
from .sales_invoice import SalesInvoice as SalesInvoice
3038
from .sales_invoice import (
3139
SalesInvoiceDetailsAttribute as SalesInvoiceDetailsAttribute,
@@ -45,6 +53,8 @@
4553
"ContactPerson",
4654
"CrudModel",
4755
"Deletable",
56+
"Document",
57+
"DocumentDetailsAttribute",
4858
"ExternalSalesInvoice",
4959
"ExternalSalesInvoiceDetailsAttribute",
5060
"FinancialMutation",
@@ -57,6 +67,10 @@
5767
"MoneybirdRateLimitError",
5868
"MoneybirdValidationError",
5969
"Payment",
70+
"PurchaseInvoice",
71+
"PurchaseInvoiceDetailsAttribute",
72+
"Receipt",
73+
"ReceiptDetailsAttribute",
6074
"SalesInvoice",
6175
"SalesInvoiceDetailsAttribute",
6276
"Saveable",

moneysnake/document.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from typing import Any, ClassVar, Self
2+
3+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
4+
5+
from .client import http_delete, http_get, http_patch, http_post, paginate
6+
from .model import Synchronizable, ensure_list_of
7+
from .payment import Payment
8+
9+
10+
class DocumentDetailsAttribute(BaseModel):
11+
"""Details attribute (line item) for a purchase invoice or receipt."""
12+
13+
id: int | None = None
14+
description: str | None = None
15+
period: str | None = None
16+
price: int | str | None = None
17+
amount: int | str | None = None
18+
tax_rate_id: int | None = None
19+
ledger_account_id: int | str | None = None
20+
project_id: int | str | None = None
21+
product_id: int | str | None = None
22+
row_order: int | None = None
23+
model_config = ConfigDict(extra="ignore")
24+
25+
def update(self, data: dict[str, Any]) -> None:
26+
validated = self.model_validate({**self.model_dump(), **data})
27+
for key in self.__class__.model_fields:
28+
object.__setattr__(self, key, getattr(validated, key))
29+
30+
31+
class Document(Synchronizable):
32+
"""Base class for Moneybird document types (purchase invoices, receipts).
33+
34+
Subclasses must set ``_resource`` (e.g. ``"purchase_invoice"``). All requests
35+
are routed under ``documents/{_resource}s``.
36+
"""
37+
38+
_resource: ClassVar[str]
39+
40+
contact_id: int | None = None
41+
contact: dict[str, Any] | None = None
42+
reference: str | None = None
43+
date: str | None = None
44+
due_date: str | None = None
45+
entry_number: int | None = None
46+
state: str | None = None
47+
currency: str | None = None
48+
exchange_rate: str | None = None
49+
revenue_invoice: bool | None = None
50+
prices_are_incl_tax: bool | None = None
51+
origin: str | None = None
52+
paid_at: str | None = None
53+
tax_number: str | None = None
54+
total_price_excl_tax: str | None = None
55+
total_price_excl_tax_base: str | None = None
56+
total_price_incl_tax: str | None = None
57+
total_price_incl_tax_base: str | None = None
58+
created_at: str | None = None
59+
updated_at: str | None = None
60+
version: int | None = None
61+
62+
details: list[DocumentDetailsAttribute] = Field(default_factory=list)
63+
payments: list[Payment] = Field(default_factory=list)
64+
65+
_destroyed_detail_ids: list[int] = PrivateAttr(default_factory=list)
66+
67+
@field_validator("payments")
68+
def ensure_payments(
69+
cls, value: list[dict[str, Any]] | list[Payment] | None
70+
) -> list[Payment]:
71+
if value is None:
72+
return []
73+
return ensure_list_of(Payment, value)
74+
75+
@field_validator("details")
76+
def ensure_details(
77+
cls,
78+
value: list[dict[str, Any]] | list[DocumentDetailsAttribute] | None,
79+
) -> list[DocumentDetailsAttribute]:
80+
if value is None:
81+
return []
82+
return ensure_list_of(DocumentDetailsAttribute, value)
83+
84+
@classmethod
85+
def _base_path(cls) -> str:
86+
return f"documents/{cls._resource}s"
87+
88+
@classmethod
89+
def _sync_endpoint(cls) -> str:
90+
return f"documents/{cls._resource}"
91+
92+
def load(self, id: int) -> None:
93+
data = http_get(f"{self._base_path()}/{id}")
94+
self.update(data)
95+
96+
@classmethod
97+
def find_by_id(cls: type[Self], id: int) -> Self:
98+
entity = cls(id=id)
99+
entity.load(id)
100+
return entity
101+
102+
def save(self) -> None:
103+
body = self.to_dict()
104+
details = body.pop("details", [])
105+
for detail_id in self._destroyed_detail_ids:
106+
details.append({"id": detail_id, "_destroy": True})
107+
body["details_attributes"] = details
108+
109+
if self.id is None:
110+
data = http_post(self._base_path(), data={self._resource: body})
111+
else:
112+
data = http_patch(
113+
f"{self._base_path()}/{self.id}", data={self._resource: body}
114+
)
115+
self._destroyed_detail_ids.clear()
116+
self.update(data)
117+
118+
def delete(self) -> None:
119+
if not self.id:
120+
raise ValueError(f"Cannot delete {self.__class__.__name__} without an id")
121+
http_delete(f"{self._base_path()}/{self.id}")
122+
self.id = None
123+
124+
def add_detail(self, detail: DocumentDetailsAttribute) -> None:
125+
self.details.append(detail)
126+
127+
def get_detail(self, detail_id: int) -> DocumentDetailsAttribute:
128+
for detail in self.details:
129+
if detail.id == detail_id:
130+
return detail
131+
raise ValueError(f"Detail with id {detail_id} not found.")
132+
133+
def update_detail(
134+
self, detail_id: int, data: dict[str, Any]
135+
) -> DocumentDetailsAttribute:
136+
detail = self.get_detail(detail_id)
137+
detail.update(data)
138+
return detail
139+
140+
def delete_detail(self, detail_id: int) -> None:
141+
detail = self.get_detail(detail_id)
142+
if detail.id is not None:
143+
self._destroyed_detail_ids.append(detail.id)
144+
self.details = [d for d in self.details if d.id != detail_id]
145+
146+
def create_payment(self, payment: Payment) -> None:
147+
data = http_post(
148+
path=f"{self._base_path()}/{self.id}/payments",
149+
data={"payment": payment.to_dict()},
150+
)
151+
payment_data = data.get("payment")
152+
if payment_data:
153+
self.payments.append(Payment(**payment_data))
154+
155+
def delete_payment(self, payment_id: int) -> None:
156+
http_delete(path=f"{self._base_path()}/{self.id}/payments/{payment_id}")
157+
self.payments = [p for p in self.payments if p.id != payment_id]
158+
159+
def register_payment(self, payment: Payment) -> None:
160+
"""Register a payment via the register_payment endpoint."""
161+
data = http_post(
162+
f"{self._base_path()}/{self.id}/register_payment",
163+
data={"payment": payment.to_dict()},
164+
)
165+
self.update(data)
166+
167+
@classmethod
168+
def list_all(
169+
cls,
170+
state: str | None = None,
171+
period: str | None = None,
172+
contact_id: int | None = None,
173+
reference: str | None = None,
174+
) -> list[Self]:
175+
"""List documents with optional filters."""
176+
filter_parts: list[str] = []
177+
if state:
178+
filter_parts.append(f"state:{state}")
179+
if period:
180+
filter_parts.append(f"period:{period}")
181+
if contact_id:
182+
filter_parts.append(f"contact_id:{contact_id}")
183+
if reference:
184+
filter_parts.append(f"reference:{reference}")
185+
186+
params: dict[str, str] = {}
187+
if filter_parts:
188+
params["filter"] = ",".join(filter_parts)
189+
190+
data = paginate(cls._base_path(), params=params or None)
191+
return [cls(**item) for item in data]

moneysnake/purchase_invoice.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .document import Document, DocumentDetailsAttribute
2+
3+
4+
class PurchaseInvoiceDetailsAttribute(DocumentDetailsAttribute):
5+
"""Details attribute (line item) for a purchase invoice."""
6+
7+
8+
class PurchaseInvoice(Document):
9+
"""Represents a purchase invoice in Moneybird."""
10+
11+
_resource = "purchase_invoice"

moneysnake/receipt.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from .document import Document, DocumentDetailsAttribute
2+
3+
4+
class ReceiptDetailsAttribute(DocumentDetailsAttribute):
5+
"""Details attribute (line item) for a receipt."""
6+
7+
8+
class Receipt(Document):
9+
"""Represents a receipt in Moneybird."""
10+
11+
_resource = "receipt"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "moneysnake"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "Moneybird SDK for Python"
55
authors = [{ name = "ArjanCodes" }]
66
license = { text = "MIT" }

tests/conftest.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,58 @@ def fixture_contact_data() -> dict[str, Any]:
8989
}
9090

9191

92+
@pytest.fixture(name="document_data")
93+
def fixture_document_data() -> dict[str, Any]:
94+
"""Full purchase invoice / receipt response matching Moneybird OpenAPI document schema."""
95+
return {
96+
"id": "480487019028416410",
97+
"administration_id": 123,
98+
"contact_id": "480487019000104856",
99+
"contact": {
100+
"id": "480487019000104856",
101+
"company_name": "Foobar Holding B.V.",
102+
},
103+
"reference": "2026-01234",
104+
"date": "2026-04-27",
105+
"due_date": "2026-05-11",
106+
"entry_number": 1,
107+
"state": "open",
108+
"currency": "EUR",
109+
"exchange_rate": "1.0",
110+
"revenue_invoice": False,
111+
"prices_are_incl_tax": False,
112+
"origin": None,
113+
"paid_at": None,
114+
"tax_number": None,
115+
"total_price_excl_tax": "99.0",
116+
"total_price_excl_tax_base": "99.0",
117+
"total_price_incl_tax": "119.79",
118+
"total_price_incl_tax_base": "119.79",
119+
"created_at": "2026-04-27T10:00:00.000Z",
120+
"updated_at": "2026-04-27T10:00:00.000Z",
121+
"version": 1772448151,
122+
"details": [
123+
{
124+
"id": "480487019122788274",
125+
"administration_id": 123,
126+
"tax_rate_id": "480486875411252364",
127+
"ledger_account_id": "480486875195245688",
128+
"project_id": None,
129+
"product_id": None,
130+
"amount": "1",
131+
"amount_decimal": "1.0",
132+
"description": "Office supplies",
133+
"price": "99.0",
134+
"period": None,
135+
}
136+
],
137+
"payments": [],
138+
"notes": [],
139+
"attachments": [],
140+
"events": [],
141+
}
142+
143+
92144
@pytest.fixture(name="payment_data")
93145
def fixture_payment_data() -> dict[str, Any]:
94146
"""Full payment response matching Moneybird OpenAPI payment_response schema."""

0 commit comments

Comments
 (0)