AstroBaaS

Orders & fulfilment

Address book with billing/shipping split

Free — GPL coresize Mplanned, not built

Generated from docs/plan/core/address-book-with-billing-shipping-split/PLAN.md in the AstroBaaS repository. Nothing described below is implemented — it is the written plan for it.

Extends order address model to support separate billing and shipping addresses. Creates customer address book tied to user account for reuse. Addresses are validated and normalized by country. Invoices use billing address.

The problem

My orders have a flat string address with no separation of billing and delivery. I can’t generate proper invoices. Customers with different billing and shipping addresses are not supported. No address book for returning customers.

What it does

  • Address entity: address_id, customer_id, type (billing | shipping | both), street, street2, city, state, postal_code, country, phone, is_default_for_type, normalized_form (for deduplication), created_at
  • Customer address book: logged-in customers can save addresses (billing and/or shipping) for reuse across orders
  • Checkout flow: show address book for returning customers; allow new address or select saved; validate before order creation
  • Order address split: Order now has billing_address_id and shipping_address_id (instead of single address string)
  • Address validation: normalize postal code format, validate country/state codes before order creation
  • Invoice generation: invoice uses Order.billing_address (name, address, city, state, zip, country)
  • Duplicate detection: if customer saves address that matches existing (normalized form), show ‘this address is already saved’ warning
  • Checkout UI: radio buttons for ‘Same as shipping’ vs ‘Different billing address’
  • Admin order edit: staff can change billing or shipping address after order creation (before fulfillment)
  • Webhook: order.address_changed when staff updates address (includes old and new)

What it deliberately does NOT do

Each boundary carries its reason. A boundary without a reason gets crossed by the next person who reads this.

  • International address parsing: out of scope—system stores user input as-is; no country-specific parsing (e.g., UK postcodes, German Hausnummer). Reason: address-parsing library adds complexity; merchants validate.
  • Address verification via external API (USPS, Google Maps): out of scope—system checks format only. Reason: requires API credentials and per-lookup cost.
  • Address book sharing between customers: out of scope—each customer has private address book. Reason: privacy; sharing is rare.
  • Business addresses vs residential (e.g., delivery restrictions): out of scope. Reason: carrier handles delivery type; no field for residential-only.
  • Apartment/suite numbering normalization: out of scope. Reason: country-specific; addresses stored as-is.
  • Subdivisional state codes (e.g., US 2-letter state): out of scope—any string accepted. Reason: not validated; carrier API validates on label generation.

Data model

Migration: new Address table/collection (address_id, customer_id, type, street, street2, city, state, postal_code, country, phone, is_default_for_type, normalized_form, created_at). Drop old address_string from Order; add billing_address_id and shipping_address_id (foreign keys to Address). Assumption: Customer already exists; authentication layer exists.

API

  • GET /customers/me/addresses (auth required) → [{address_id, type, street, city, postal_code, is_default_for_type}]
  • POST /customers/me/addresses (auth required, body: {type, street, street2, city, state, postal_code, country, phone, is_default_for_type?}) → {address_id}
  • PUT /customers/me/addresses/:id (auth required) → {address_id}
  • DELETE /customers/me/addresses/:id (auth required) → 204
  • POST /addresses/validate (public, body: {street, city, postal_code, country}) → {valid: true, normalized: {…}}
  • POST /orders (body: {billing_address_id, shipping_address_id, …}) → {order_id}
  • PUT /orders/:id/addresses (staff, body: {billing_address_id?, shipping_address_id?}) → {order_id}

Every route added here must also appear in src/pages/openapi.json.ts — a test fails the build if it does not.

Admin

Order detail shows Billing Address and Shipping Address in separate sections. Edit button on each to change address (triggers order.address_changed webhook). Customer detail shows address book with type tags (Billing, Shipping, Both) and default indicators.

The seam — why this is core

Core owns Address entity, validation, and order separation. Paid pack owns: international address parsing, third-party verification API integration, business address type detection. Why: core provides honest local storage and format validation; international parsing and verification are paid.

Core order interface. A legal invoice requires a billing address. Must ship before commerce claim.

Dependencies

  • orders (orders must support billing_address_id and shipping_address_id)
  • authentication (customer_id must exist for address book)
  • invoices (invoices must reference billing address)

Acceptance checks

Each of these must be able to fail. Before claiming this is done, break the code deliberately and watch each one go red.

  • Customer saves address with street=‘123 Main St’, city=‘Springfield’, state=‘IL’, postal_code=‘62701’, country=‘US’; Address created and returned
  • GET /customers/me/addresses returns all saved addresses for authenticated user
  • Checkout: customer selects saved billing address from dropdown, new shipping address; order created with both addresses
  • POST /addresses/validate with valid US zip returns {valid: true}; invalid zip returns {valid: false, errors: [‘postal_code.invalid’]}
  • Order detail shows Billing Address and Shipping Address separately; invoice renders billing_address_id
  • Staff edit order shipping address; new address saved and order.address_changed webhook fired with old/new
  • Two identical addresses saved: system shows warning ‘This address already exists’ and suggests using existing
  • is_default_for_type=true: when customer saves another shipping address, old default is unset
  • Address with street2=‘Apt 5B’ is stored and displayed in full; not truncated
  • PUT /orders/123/addresses with invalid address_id returns 404 with ‘address.not_found’

Risks

Schema: splitting address into two IDs requires migration of existing orders (backfill NULL or a default address). Address validation: if format validation is too strict, international customers cannot order. Duplicate detection: normalized_form must be consistent (case-insensitive, trim whitespace) or duplicates are not caught. Order edit: if address changed after shipment, label has wrong address and parcel is misdelivered.

Commercial context

Suggested priceCore
Rival anchorMagento Open Source: full address entities on both sides, free.

The anchor is what the nearest equivalent charges on Shopify or Magento today. It is context for a pricing decision, not the decision.


Generated from the commerce plan. See docs/COMMERCE-PLAN.md for the full catalogue and ../../AI-GUIDE.md for how to work on this repository.