Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions backend/migrations/20260527000001_create_ledger_addresses_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""
Create ledger_addresses table for storing multiple named shipping/delivery
addresses per ledger (buyer), scoped to a company.
"""

from sqlalchemy import text


def up(conn) -> None:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS ledger_addresses (
id SERIAL PRIMARY KEY,
ledger_id INTEGER NOT NULL REFERENCES buyers(id) ON DELETE CASCADE,
company_id INTEGER NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
address TEXT NOT NULL,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""))

conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_ledger_addresses_ledger_id
ON ledger_addresses(ledger_id);
"""))

conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_ledger_addresses_company_id
ON ledger_addresses(company_id);
"""))


def down(conn) -> None:
conn.execute(text("DROP TABLE IF EXISTS ledger_addresses;"))
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Add shipping_address and shipping_address_label snapshot columns to invoices.
These are populated at invoice creation time and are independent of any changes
to the saved ledger_addresses records.
"""

from sqlalchemy import text


def up(conn) -> None:
conn.execute(text("""
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS shipping_address TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS shipping_address_label VARCHAR(255) DEFAULT NULL;
"""))


def down(conn) -> None:
conn.execute(text("""
ALTER TABLE invoices
DROP COLUMN IF EXISTS shipping_address,
DROP COLUMN IF EXISTS shipping_address_label;
"""))
112 changes: 111 additions & 1 deletion backend/src/api/routes/ledgers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
from src.models.credit_note import CreditNote, CreditNoteItem
from src.models.invoice import Invoice
from src.models.invoice import InvoiceItem
from src.models.ledger_address import LedgerAddress
from src.models.payment import Payment, PaymentInvoiceAllocation
from src.models.user import User, UserRole
from src.schemas.invoice import OutstandingInvoiceOut
from src.schemas.ledger import DayBookEntry, DayBookOut, LedgerCreate, LedgerOut, LedgerStatementEntry, LedgerStatementInvoiceAllocation, LedgerStatementOut, PaginatedLedgerOut, TaxLedgerEntry, TaxLedgerOut, TaxLedgerTotals
from src.schemas.ledger_address import LedgerAddressCreate, LedgerAddressOut, LedgerAddressUpdate
from src.services.credit_note_reporting import get_credit_note_ledger_summary
from src.services.financial_year import get_active_fy
from src.services.invoice_payments import auto_allocate_outstanding_invoices, build_invoice_payment_summaries, get_outstanding_invoices_for_ledger
Expand Down Expand Up @@ -1094,4 +1096,112 @@ def download_ledger_statement_pdf(
buf,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
)


# ---------------------------------------------------------------------------
# Ledger address endpoints
# ---------------------------------------------------------------------------

def _get_ledger_or_404(db: Session, ledger_id: int, company_id: int | None) -> Ledger:
query = db.query(Ledger).filter(Ledger.id == ledger_id)
if company_id is not None:
query = query.filter(or_(Ledger.company_id == company_id, Ledger.company_id.is_(None)))
ledger = query.first()
if not ledger:
raise HTTPException(status_code=404, detail=f"Ledger {ledger_id} not found")
return ledger


@router.get("/{ledger_id}/addresses/", response_model=list[LedgerAddressOut])
def list_ledger_addresses(
ledger_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
active_company: CompanyProfile = Depends(get_active_company),
):
"""List all saved addresses for a ledger."""
company_id = active_company.id
_get_ledger_or_404(db, ledger_id, company_id)
return (
db.query(LedgerAddress)
.filter(LedgerAddress.ledger_id == ledger_id, LedgerAddress.company_id == company_id)
.order_by(LedgerAddress.is_default.desc(), LedgerAddress.created_at.asc())
.all()
)


@router.post("/{ledger_id}/addresses/", response_model=LedgerAddressOut, status_code=201)
def create_ledger_address(
ledger_id: int,
payload: LedgerAddressCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
active_company: CompanyProfile = Depends(get_active_company),
):
"""Create a new saved address for a ledger."""
company_id = active_company.id
_get_ledger_or_404(db, ledger_id, company_id)
addr = LedgerAddress(
ledger_id=ledger_id,
company_id=company_id,
label=payload.label,
address=payload.address,
is_default=payload.is_default,
)
db.add(addr)
db.commit()
db.refresh(addr)
return addr


@router.put("/{ledger_id}/addresses/{address_id}", response_model=LedgerAddressOut)
def update_ledger_address(
ledger_id: int,
address_id: int,
payload: LedgerAddressUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
active_company: CompanyProfile = Depends(get_active_company),
):
"""Update a saved ledger address."""
company_id = active_company.id
_get_ledger_or_404(db, ledger_id, company_id)
addr = db.query(LedgerAddress).filter(
LedgerAddress.id == address_id,
LedgerAddress.ledger_id == ledger_id,
LedgerAddress.company_id == company_id,
).first()
if not addr:
raise HTTPException(status_code=404, detail="Address not found")
if payload.label is not None:
addr.label = payload.label
if payload.address is not None:
addr.address = payload.address
if payload.is_default is not None:
addr.is_default = payload.is_default
db.commit()
db.refresh(addr)
return addr


@router.delete("/{ledger_id}/addresses/{address_id}", status_code=204)
def delete_ledger_address(
ledger_id: int,
address_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
active_company: CompanyProfile = Depends(get_active_company),
):
"""Delete a saved ledger address."""
company_id = active_company.id
_get_ledger_or_404(db, ledger_id, company_id)
addr = db.query(LedgerAddress).filter(
LedgerAddress.id == address_id,
LedgerAddress.ledger_id == ledger_id,
LedgerAddress.company_id == company_id,
).first()
if not addr:
raise HTTPException(status_code=404, detail="Address not found")
db.delete(addr)
db.commit()
1 change: 1 addition & 0 deletions backend/src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from src.models.inventory import Inventory
from src.models.invoice import Invoice, InvoiceItem
from src.models.buyer import Buyer
from src.models.ledger_address import LedgerAddress
from src.models.company import CompanyProfile
from src.models.company_account import CompanyAccount
from src.models.payment import Payment, PaymentInvoiceAllocation
Expand Down
2 changes: 2 additions & 0 deletions backend/src/models/buyer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from src.db.base import Base



class Buyer(Base):
__tablename__ = "buyers"

Expand All @@ -23,3 +24,4 @@ class Buyer(Base):
created_at = Column(DateTime, default=datetime.utcnow)

invoices = relationship("Invoice", back_populates="ledger")
addresses = relationship("LedgerAddress", back_populates="ledger", cascade="all, delete-orphan")
2 changes: 2 additions & 0 deletions backend/src/models/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class Invoice(Base):
apply_round_off = Column(Boolean, nullable=False, default=False)
round_off_amount = Column(Numeric(5, 2), nullable=False, default=0)
financial_year_id = Column(Integer, ForeignKey("financial_years.id"), nullable=True)
shipping_address = Column(Text, nullable=True)
shipping_address_label = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

ledger = relationship("Buyer", back_populates="invoices")
Expand Down
18 changes: 18 additions & 0 deletions backend/src/models/ledger_address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from datetime import datetime
from src.db.base import Base


class LedgerAddress(Base):
__tablename__ = "ledger_addresses"

id = Column(Integer, primary_key=True, index=True)
ledger_id = Column(Integer, ForeignKey("buyers.id", ondelete="CASCADE"), nullable=False, index=True)
company_id = Column(Integer, ForeignKey("company_profiles.id", ondelete="CASCADE"), nullable=False, index=True)
label = Column(String(255), nullable=False)
address = Column(Text, nullable=False)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=datetime.utcnow)

ledger = relationship("Buyer", back_populates="addresses")
6 changes: 6 additions & 0 deletions backend/src/schemas/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pydantic import BaseModel, Field
from typing import List, Literal, Optional
from src.schemas.ledger import LedgerOut
from src.schemas.ledger_address import ShippingAddressInline


class InvoiceItemCreate(BaseModel):
Expand All @@ -20,6 +21,9 @@ class InvoiceCreate(BaseModel):
reference_notes: str | None = None
tax_inclusive: bool = False
apply_round_off: bool = False
shipping_address_same_as_billing: bool = True
shipping_address_id: int | None = None
new_shipping_address: ShippingAddressInline | None = None
items: List[InvoiceItemCreate]


Expand Down Expand Up @@ -85,6 +89,8 @@ class InvoiceOut(BaseModel):
apply_round_off: bool = False
round_off_amount: float = 0
financial_year_id: Optional[int] = None
shipping_address: str | None = None
shipping_address_label: str | None = None
warnings: List[str] = Field(default_factory=list)
created_at: datetime
items: list[InvoiceItemOut] = Field(default_factory=list)
Expand Down
34 changes: 34 additions & 0 deletions backend/src/schemas/ledger_address.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from datetime import datetime
from pydantic import BaseModel


class LedgerAddressCreate(BaseModel):
label: str
address: str
is_default: bool = False


class LedgerAddressUpdate(BaseModel):
label: str | None = None
address: str | None = None
is_default: bool | None = None


class LedgerAddressOut(BaseModel):
id: int
ledger_id: int
company_id: int
label: str
address: str
is_default: bool
created_at: datetime

class Config:
from_attributes = True


class ShippingAddressInline(BaseModel):
"""Represents a new shipping address entered inline during invoicing.
It will be auto-saved to ledger_addresses and snapshotted onto the invoice."""
label: str
address: str
65 changes: 65 additions & 0 deletions backend/src/services/invoice_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from src.models.buyer import Buyer as Ledger
from src.models.company import CompanyProfile
from src.models.invoice import Invoice, InvoiceItem
from src.models.ledger_address import LedgerAddress
from src.models.product import Product
from src.schemas.invoice import InvoiceCreate
from src.services.gst_tax_service import (
Expand Down Expand Up @@ -305,6 +306,10 @@ def apply_payload(
invoice.voucher_type = payload.voucher_type
invoice.supplier_invoice_number = payload.supplier_invoice_number
invoice.reference_notes = payload.reference_notes

# Snapshot shipping address
self._apply_shipping_address(invoice, payload, ledger.id, company_id)

if created_by is not None:
invoice.created_by = created_by
if financial_year_id is not None:
Expand Down Expand Up @@ -398,3 +403,63 @@ def apply_payload(
else:
invoice.round_off_amount = 0
invoice.total_amount = float(raw_total)

# ------------------------------------------------------------------
# Shipping address helpers
# ------------------------------------------------------------------

def _apply_shipping_address(
self,
invoice: Invoice,
payload: InvoiceCreate,
ledger_id: int,
company_id: int | None,
) -> None:
"""Resolve and snapshot the shipping address onto the invoice.

Priority:
1. same_as_billing=True → clear shipping fields (null)
2. shipping_address_id → load existing LedgerAddress row and snapshot
3. new_shipping_address → persist to ledger_addresses then snapshot
"""
if payload.shipping_address_same_as_billing:
invoice.shipping_address = None
invoice.shipping_address_label = None
return

if payload.shipping_address_id is not None:
addr = self.db.query(LedgerAddress).filter(
LedgerAddress.id == payload.shipping_address_id,
LedgerAddress.ledger_id == ledger_id,
).first()
if addr is None:
raise HTTPException(
status_code=404,
detail=f"Shipping address {payload.shipping_address_id} not found for this ledger",
)
invoice.shipping_address = addr.address
invoice.shipping_address_label = addr.label
return

if payload.new_shipping_address is not None:
if company_id is None:
raise HTTPException(
status_code=400,
detail="Cannot save a new shipping address without an active company",
)
new_addr = LedgerAddress(
ledger_id=ledger_id,
company_id=company_id,
label=payload.new_shipping_address.label,
address=payload.new_shipping_address.address,
is_default=False,
)
self.db.add(new_addr)
self.db.flush() # get id without committing
invoice.shipping_address = new_addr.address
invoice.shipping_address_label = new_addr.label
return

# No shipping address provided and same_as_billing=False — treat as same
invoice.shipping_address = None
invoice.shipping_address_label = None
Loading
Loading