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"

{_e(invoice.shipping_address_label)}

" if invoice.shipping_address_label else "" + ) + ship_to_html = f""" +
+

Ship to

+ {ship_label_html} +

{_e(invoice.shipping_address)}

+
""" + + # Wrap bill-to and optional ship-to in a flex row + bill_and_ship_html = f""" +
+
+

Bill to

+

{_e(invoice.ledger_name) or 'Unknown ledger'}

+

{_e(invoice.ledger_address) or 'Address not provided'}

+

{billto_details}

+
{ship_to_html} +
""" + invoice_title = "Tax Invoice" if invoice.ledger_gst else "Sales Invoice" round_off_amount = float(invoice.round_off_amount or 0) @@ -205,6 +229,15 @@ def _build_invoice_html(invoice: Invoice, products: list[Product], invoice_bank_ color: #4b5563; margin-bottom: 1px; }} + .invoice-sheet__addresses {{ + display: flex; + gap: 12px; + margin-bottom: 16px; + }} + .invoice-sheet__addresses .invoice-sheet__billto {{ + flex: 1; + margin-bottom: 0; + }} .invoice-sheet__reference-notes {{ border: 1px dashed #d1d5db; border-radius: 6px; @@ -361,12 +394,7 @@ def _build_invoice_html(invoice: Invoice, products: list[Product], invoice_bank_ -
-

Bill to

-

{_e(invoice.ledger_name) or 'Unknown ledger'}

-

{_e(invoice.ledger_address) or 'Address not provided'}

-

{billto_details}

-
+ {bill_and_ship_html} {reference_notes_html} diff --git a/frontend/src/features/invoices/api.ts b/frontend/src/features/invoices/api.ts index 1ad84aa..79b5df1 100644 --- a/frontend/src/features/invoices/api.ts +++ b/frontend/src/features/invoices/api.ts @@ -1,5 +1,5 @@ import api from '../../api/client'; -import type { CompanyProfile, Invoice, Ledger, OutstandingInvoice, PaginatedInvoices, Product } from '../../types/api'; +import type { CompanyProfile, Invoice, Ledger, LedgerAddress, LedgerAddressCreate, LedgerAddressUpdate, OutstandingInvoice, PaginatedInvoices, Product } from '../../types/api'; type InvoiceFilters = { page: number; @@ -154,3 +154,26 @@ export async function fetchInvoiceComposerData(input: { company: companyRes, }; } + +// --------------------------------------------------------------------------- +// Ledger address helpers +// --------------------------------------------------------------------------- + +export async function fetchLedgerAddresses(ledgerId: number): Promise { + const res = await api.get(`/ledgers/${ledgerId}/addresses/`); + return res.data; +} + +export async function createLedgerAddress(ledgerId: number, data: LedgerAddressCreate): Promise { + const res = await api.post(`/ledgers/${ledgerId}/addresses/`, data); + return res.data; +} + +export async function updateLedgerAddress(ledgerId: number, addressId: number, data: LedgerAddressUpdate): Promise { + const res = await api.put(`/ledgers/${ledgerId}/addresses/${addressId}`, data); + return res.data; +} + +export async function deleteLedgerAddress(ledgerId: number, addressId: number): Promise { + await api.delete(`/ledgers/${ledgerId}/addresses/${addressId}`); +} diff --git a/frontend/src/pages/LedgerViewPage.tsx b/frontend/src/pages/LedgerViewPage.tsx index 9d7cacf..2210028 100644 --- a/frontend/src/pages/LedgerViewPage.tsx +++ b/frontend/src/pages/LedgerViewPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, ChevronDown, FileText, FilePlus, Mail, Pencil, ReceiptText, Trash2 } from 'lucide-react'; import api, { getApiErrorMessage } from '../api/client'; -import type { CompanyAccount, CompanyProfile, Invoice, Ledger, LedgerStatement, OutstandingInvoice, Payment, PaymentCreate, PaymentInvoiceAllocation, PaymentUpdate, Product } from '../types/api'; +import type { CompanyAccount, CompanyProfile, Invoice, Ledger, LedgerAddress, LedgerStatement, OutstandingInvoice, Payment, PaymentCreate, PaymentInvoiceAllocation, PaymentUpdate, Product } from '../types/api'; import InvoicePreview from '../components/InvoicePreview'; import PaymentReceiptPreview from '../components/PaymentReceiptPreview'; import StatementPreview from '../components/StatementPreview'; @@ -12,7 +12,7 @@ import SendEmailModal from '../components/SendEmailModal'; import ConfirmDialog from '../components/ConfirmDialog'; import formatCurrency from '../utils/formatting'; import { useFY } from '../context/FYContext'; -import { fetchOutstandingInvoices } from '../features/invoices/api'; +import { fetchOutstandingInvoices, fetchLedgerAddresses, createLedgerAddress, updateLedgerAddress, deleteLedgerAddress } from '../features/invoices/api'; import { formatInvoiceDateLabel } from '../utils/invoiceDueDate.ts'; import EmptyState from '../components/EmptyState'; @@ -137,6 +137,13 @@ export default function LedgerViewPage() { const [deletingPayment, setDeletingPayment] = useState(false); const [allocationCreateSearch, setAllocationCreateSearch] = useState(''); const [allocationEditSearch, setAllocationEditSearch] = useState(''); + // Address management state + const [addresses, setAddresses] = useState([]); + const [addressFormLabel, setAddressFormLabel] = useState(''); + const [addressFormText, setAddressFormText] = useState(''); + const [editingAddressId, setEditingAddressId] = useState(null); + const [showAddressForm, setShowAddressForm] = useState(false); + const [submittingAddress, setSubmittingAddress] = useState(false); useEffect(() => { if (!showPaymentForm) setAllocationCreateSearch(''); @@ -146,6 +153,68 @@ export default function LedgerViewPage() { if (!editingPayment) setAllocationEditSearch(''); }, [editingPayment]); + // Load ledger addresses + useEffect(() => { + if (!ledgerId) return; + let cancelled = false; + fetchLedgerAddresses(ledgerId) + .then((addrs) => { if (!cancelled) setAddresses(addrs); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [ledgerId, refreshKey]); + + async function handleAddressSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!addressFormText.trim()) return; + setSubmittingAddress(true); + try { + if (editingAddressId !== null) { + const updated = await updateLedgerAddress(ledgerId, editingAddressId, { + label: addressFormLabel.trim() || 'Address', + address: addressFormText.trim(), + }); + setAddresses((prev) => prev.map((a) => (a.id === editingAddressId ? updated : a))); + } else { + const created = await createLedgerAddress(ledgerId, { + label: addressFormLabel.trim() || 'Address', + address: addressFormText.trim(), + }); + setAddresses((prev) => [...prev, created]); + } + setShowAddressForm(false); + setEditingAddressId(null); + setAddressFormLabel(''); + setAddressFormText(''); + } catch (err) { + setError(getApiErrorMessage(err, 'Unable to save address')); + } finally { + setSubmittingAddress(false); + } + } + + async function handleAddressDelete(addressId: number) { + try { + await deleteLedgerAddress(ledgerId, addressId); + setAddresses((prev) => prev.filter((a) => a.id !== addressId)); + } catch (err) { + setError(getApiErrorMessage(err, 'Unable to delete address')); + } + } + + function startEditingAddress(addr: LedgerAddress) { + setEditingAddressId(addr.id); + setAddressFormLabel(addr.label); + setAddressFormText(addr.address); + setShowAddressForm(true); + } + + function cancelAddressForm() { + setShowAddressForm(false); + setEditingAddressId(null); + setAddressFormLabel(''); + setAddressFormText(''); + } + useEffect(() => { if (!showActionsDropdown) return; function handleClickOutside(e: MouseEvent) { @@ -662,6 +731,97 @@ export default function LedgerViewPage() { +
+
+
+

Saved addresses

+

Shipping / delivery addresses

+
+ {!showAddressForm ? ( + + ) : null} +
+ + {showAddressForm ? ( +
+
+
+ + setAddressFormLabel(e.target.value)} + placeholder="e.g. Warehouse, Site A" + /> +
+
+ +