Saved addresses
+Shipping / delivery addresses
+No saved addresses yet. Add one to use it as a shipping address when invoicing.
+ ) : null} + + {addresses.map((addr) => ( +{addr.label}
+{addr.address}
+diff --git a/backend/migrations/20260527000001_create_ledger_addresses_table.py b/backend/migrations/20260527000001_create_ledger_addresses_table.py new file mode 100644 index 0000000..54f43e7 --- /dev/null +++ b/backend/migrations/20260527000001_create_ledger_addresses_table.py @@ -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;")) diff --git a/backend/migrations/20260527000002_add_shipping_address_to_invoices.py b/backend/migrations/20260527000002_add_shipping_address_to_invoices.py new file mode 100644 index 0000000..a1fa3a7 --- /dev/null +++ b/backend/migrations/20260527000002_add_shipping_address_to_invoices.py @@ -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; + """)) diff --git a/backend/src/api/routes/ledgers.py b/backend/src/api/routes/ledgers.py index 3007f22..e12bc42 100644 --- a/backend/src/api/routes/ledgers.py +++ b/backend/src/api/routes/ledgers.py @@ -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 @@ -1094,4 +1096,112 @@ def download_ledger_statement_pdf( buf, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) \ No newline at end of file + ) + + +# --------------------------------------------------------------------------- +# 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() \ No newline at end of file diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index c5d302b..525045e 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -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 diff --git a/backend/src/models/buyer.py b/backend/src/models/buyer.py index f53b7d0..9f0aa7a 100644 --- a/backend/src/models/buyer.py +++ b/backend/src/models/buyer.py @@ -4,6 +4,7 @@ from src.db.base import Base + class Buyer(Base): __tablename__ = "buyers" @@ -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") diff --git a/backend/src/models/invoice.py b/backend/src/models/invoice.py index a052181..d68ccfb 100644 --- a/backend/src/models/invoice.py +++ b/backend/src/models/invoice.py @@ -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") diff --git a/backend/src/models/ledger_address.py b/backend/src/models/ledger_address.py new file mode 100644 index 0000000..729ff04 --- /dev/null +++ b/backend/src/models/ledger_address.py @@ -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") diff --git a/backend/src/schemas/invoice.py b/backend/src/schemas/invoice.py index c6a1238..0b91a0e 100644 --- a/backend/src/schemas/invoice.py +++ b/backend/src/schemas/invoice.py @@ -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): @@ -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] @@ -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) diff --git a/backend/src/schemas/ledger_address.py b/backend/src/schemas/ledger_address.py new file mode 100644 index 0000000..e1bdf13 --- /dev/null +++ b/backend/src/schemas/ledger_address.py @@ -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 diff --git a/backend/src/services/invoice_processor.py b/backend/src/services/invoice_processor.py index 16a05eb..b7d5e44 100644 --- a/backend/src/services/invoice_processor.py +++ b/backend/src/services/invoice_processor.py @@ -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 ( @@ -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: @@ -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 diff --git a/backend/src/services/pdf_templates/invoice_template.py b/backend/src/services/pdf_templates/invoice_template.py index 85c59b8..235cd3f 100644 --- a/backend/src/services/pdf_templates/invoice_template.py +++ b/backend/src/services/pdf_templates/invoice_template.py @@ -104,6 +104,30 @@ def _build_invoice_html(invoice: Invoice, products: list[Product], invoice_bank_ billto_parts.append(f"Phone: {_e(invoice.ledger_phone)}") billto_details = " · ".join(billto_parts) + # Ship-to section — only rendered when shipping address differs from billing + ship_to_html = "" + if invoice.shipping_address: + ship_label_html = ( + f"
Ship to
+ {ship_label_html} +{_e(invoice.shipping_address)}
+Bill to
+{_e(invoice.ledger_address) or 'Address not provided'}
+{billto_details}
+Bill to
-{_e(invoice.ledger_address) or 'Address not provided'}
-{billto_details}
-Saved addresses
+No saved addresses yet. Add one to use it as a shipping address when invoicing.
+ ) : null} + + {addresses.map((addr) => ( +{addr.label}
+{addr.address}
+