Skip to content

Commit

Permalink
Custom shipping cost (#86)
Browse files Browse the repository at this point in the history
* feat: simple UI for shipping zone per product

* feat: add empty cost

* fix: backwards compatible zones

* feat: finish UI for product shipping cost

* fix: some ui issues

* feat: add per product shipping cost

* feat: show receipt for product

* fix: publish per product shipping cost
  • Loading branch information
motorina0 authored Sep 21, 2023
1 parent 1a840ac commit f29e77d
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 81 deletions.
62 changes: 56 additions & 6 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,17 @@ def from_row(cls, row: Row) -> "Stall":
######################################## PRODUCTS ########################################


class ProductShippingCost(BaseModel):
id: str
cost: int


class ProductConfig(BaseModel):
description: Optional[str]
currency: Optional[str]
use_autoreply: Optional[bool] = False
autoreply_message: Optional[str]
shipping: Optional[List[ProductShippingCost]] = []


class PartialProduct(BaseModel):
Expand Down Expand Up @@ -251,6 +257,7 @@ def to_nostr_event(self, pubkey: str) -> NostrEvent:
"currency": self.config.currency,
"price": self.price,
"quantity": self.quantity,
"shipping": [dict(s) for s in self.config.shipping or []]
}
categories = [["t", tag] for tag in self.categories]

Expand Down Expand Up @@ -358,24 +365,67 @@ def validate_order_items(self, product_list: List[Product]):
)

async def costs_in_sats(
self, products: List[Product], shipping_cost: float
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
) -> Tuple[float, float]:
product_prices = {}
for p in products:
product_prices[p.id] = p
product_shipping_cost = next(
(s.cost for s in p.config.shipping if s.id == shipping_id), 0
)
product_prices[p.id] = {
"price": p.price + product_shipping_cost,
"currency": p.config.currency or "sat",
}

product_cost: float = 0 # todo
for item in self.items:
price = product_prices[item.product_id].price
currency = product_prices[item.product_id].config.currency or "sat"
assert item.quantity > 0, "Quantity cannot be negative"
price = product_prices[item.product_id]["price"]
currency = product_prices[item.product_id]["currency"]
if currency != "sat":
price = await fiat_amount_as_satoshis(price, currency)
product_cost += item.quantity * price

if currency != "sat":
shipping_cost = await fiat_amount_as_satoshis(shipping_cost, currency)
stall_shipping_cost = await fiat_amount_as_satoshis(
stall_shipping_cost, currency
)

return product_cost, stall_shipping_cost

def receipt(
self, products: List[Product], shipping_id: str, stall_shipping_cost: float
) -> str:
if len(products) == 0:
return "[No Products]"
receipt = ""
product_prices = {}
for p in products:
product_shipping_cost = next(
(s.cost for s in p.config.shipping if s.id == shipping_id), 0
)
product_prices[p.id] = {
"name": p.name,
"price": p.price,
"product_shipping_cost": product_shipping_cost
}

currency = products[0].config.currency or "sat"
products_cost: float = 0 # todo
items_receipts = []
for item in self.items:
prod = product_prices[item.product_id]
price = prod["price"] + prod["product_shipping_cost"]

products_cost += item.quantity * price

items_receipts.append(f"""[{prod["name"]}: {item.quantity} x ({prod["price"]} + {prod["product_shipping_cost"]}) = {item.quantity * price} {currency}] """)

receipt = "; ".join(items_receipts)
receipt += f"[Products cost: {products_cost} {currency}] [Stall shipping cost: {stall_shipping_cost} {currency}]; "
receipt += f"[Total: {products_cost + stall_shipping_cost} {currency}]"

return product_cost, shipping_cost
return receipt


class Order(PartialOrder):
Expand Down
13 changes: 8 additions & 5 deletions services.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ async def create_new_order(
if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None

order, invoice = await build_order_with_payment(
order, invoice, receipt = await build_order_with_payment(
merchant.id, merchant.public_key, data
)
await create_order(merchant.id, order)

return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)]
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
)


Expand All @@ -90,7 +90,10 @@ async def build_order_with_payment(
assert shipping_zone, f"Shipping zone not found for order '{data.id}'"

product_cost_sat, shipping_cost_sat = await data.costs_in_sats(
products, shipping_zone.cost
products, shipping_zone.id, shipping_zone.cost
)
receipt = data.receipt(
products, shipping_zone.id, shipping_zone.cost
)

wallet_id = await get_wallet_for_product(data.items[0].product_id)
Expand Down Expand Up @@ -126,7 +129,7 @@ async def build_order_with_payment(
extra=extra,
)

return order, invoice
return order, invoice, receipt


async def update_merchant_to_nostr(
Expand Down Expand Up @@ -567,7 +570,7 @@ async def _handle_new_order(
except Exception as e:
logger.debug(e)
payment_req = await create_new_failed_order(
merchant_id, merchant_public_key, dm, json_data, str(e)
merchant_id, merchant_public_key, dm, json_data, "Order received, but cannot be processed. Please contact merchant."
)

response = {
Expand Down
76 changes: 49 additions & 27 deletions static/components/stall-details/stall-details.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,20 +102,11 @@
</q-td>

<q-td key="id" :props="props"> {{props.row.id}} </q-td>
<q-td key="name" :props="props"> {{props.row.name}} </q-td>
<q-td key="name" :props="props"> {{shortLabel(props.row.name)}} </q-td>
<q-td key="price" :props="props"> {{props.row.price}} </q-td>
<q-td key="quantity" :props="props">
{{props.row.quantity}}
</q-td>

<q-td key="categories" :props="props">
<div>
{{props.row.categories.filter(c => c).join(', ')}}
</div>
</q-td>
<q-td key="description" :props="props">
{{props.row.config.description}}
</q-td>
</q-tr>
</template>
</q-table>
Expand All @@ -131,35 +122,66 @@
</q-tab-panel>
</q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top">
<q-card v-if="stall" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-card v-if="stall && productDialog.data" class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-input filled dense v-model.trim="productDialog.data.name" label="Name"></q-input>

<q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></q-input>
<q-select filled multiple dense emit-value v-model.trim="productDialog.data.categories" use-input use-chips
multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>

<q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
label="Image URL">
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
<div class="row q-mb-sm">
<div class="col">
<q-input filled dense v-model.number="productDialog.data.price" type="number"
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
</div>
<div class="col q-ml-md">
<q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>
</div>
</div>

<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
<q-input filled dense v-model.number="productDialog.data.price" type="number"
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
<q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>

<q-expansion-item group="advanced" label="Categories"
caption="Add tags to producsts, make them easy to search.">
<div class="q-pl-sm q-pt-sm">
<q-select filled multiple dense emit-value v-model.trim="productDialog.data.categories" use-input use-chips
multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>
</div>
</q-expansion-item>

<q-expansion-item group="advanced" icon="settings" label="Advanced options">
<q-expansion-item group="advanced" label="Images" caption="Add images for product.">
<div class="q-pl-sm q-pt-sm">
<q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
label="Image URL">
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>

<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span>
</q-chip>
</div>
</q-expansion-item>


<q-expansion-item group="advanced" label="Custom Shipping Cost"
caption="Configure custom shipping costs for this product">
<div v-for="zone of productDialog.data.config.shipping" class="row q-mb-sm q-ml-lg q-mt-sm">
<div class="col">
<span v-text="zone.name"></span>
</div>
<div class="col q-pr-md">
<q-input v-model="zone.cost" filled dense type="number" label="Extra cost">
</q-input>
</div>
</div>
</q-expansion-item>
<q-expansion-item group="advanced" label="Autoreply" caption="Autoreply when paid">
<q-card>
<q-card-section>
<div class="row q-mb-sm">
<div class="col">
<q-checkbox v-model="productDialog.data.config.use_autoreply" dense label="Autoreply when paid" />
<q-checkbox v-model="productDialog.data.config.use_autoreply" dense
label="Send a direct message when paid" class="q-ml-sm" />
</div>
</div>

Expand Down
72 changes: 33 additions & 39 deletions static/components/stall-details/stall-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,7 @@ async function stallDetails(path) {
showDialog: false,
showRestore: false,
url: true,
data: {
id: null,
name: '',
categories: [],
images: [],
image: null,
price: 0,

quantity: 0,
config: {
description: ''
}
}
data: null
},
productsFilter: '',
productsTable: {
Expand Down Expand Up @@ -76,18 +64,6 @@ async function stallDetails(path) {
align: 'left',
label: 'Quantity',
field: 'quantity'
},
{
name: 'categories',
align: 'left',
label: 'Categories',
field: 'categories'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
}
],
pagination: {
Expand All @@ -112,6 +88,24 @@ async function stallDetails(path) {
)
return stall
},
newEmtpyProductData: function() {
return {
id: null,
name: '',
categories: [],
images: [],
image: null,
price: 0,

quantity: 0,
config: {
description: '',
use_autoreply: false,
autoreply_message: '',
shipping: (this.stall.shipping_zones || []).map(z => ({id: z.id, name: z.name, cost: 0}))
}
}
},
getStall: async function () {
try {
const { data } = await LNbits.api.request(
Expand Down Expand Up @@ -202,7 +196,7 @@ async function stallDetails(path) {
}
},
sendProductFormData: function () {
var data = {
const data = {
stall_id: this.stall.id,
id: this.productDialog.data.id,
name: this.productDialog.data.name,
Expand Down Expand Up @@ -265,7 +259,14 @@ async function stallDetails(path) {
}
},
editProduct: async function (product) {
const emptyShipping = this.newEmtpyProductData().config.shipping
this.productDialog.data = { ...product }
this.productDialog.data.config.shipping = emptyShipping.map(shippingZone => {
const existingShippingCost = (product.config.shipping || []).find(ps => ps.id === shippingZone.id)
shippingZone.cost = existingShippingCost?.cost || 0
return shippingZone
})

this.productDialog.showDialog = true
},
deleteProduct: async function (productId) {
Expand Down Expand Up @@ -293,19 +294,7 @@ async function stallDetails(path) {
})
},
showNewProductDialog: async function (data) {
this.productDialog.data = data || {
id: null,
name: '',
description: '',
categories: [],
image: null,
images: [],
price: 0,
quantity: 0,
config: {
description: ''
}
}
this.productDialog.data = data || this.newEmtpyProductData()
this.productDialog.showDialog = true
},
openSelectPendingProductDialog: async function () {
Expand All @@ -324,11 +313,16 @@ async function stallDetails(path) {
},
customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = ''){
if (value.length <= 44) return value
return value.substring(0, 40) + '...'
}
},
created: async function () {
await this.getStall()
this.products = await this.getProducts()
this.productDialog.data = this.newEmtpyProductData()
}
})
}
7 changes: 3 additions & 4 deletions static/components/stall-list/stall-list.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,14 @@
:icon="props.row.expanded? 'remove' : 'add'" />
</q-td>

<q-td key="id" :props="props"> {{props.row.name}} </q-td>
<q-td key="id" :props="props"> {{shortLabel(props.row.name)}} </q-td>
<q-td key="currency" :props="props"> {{props.row.currency}} </q-td>
<q-td key="description" :props="props">
{{props.row.config.description}}
{{shortLabel(props.row.config.description)}}
</q-td>
<q-td key="shippingZones" :props="props">
<div>
{{props.row.shipping_zones.filter(z => !!z.name).map(z =>
z.name).join(', ')}}
{{shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))}}
</div>
</q-td>
</q-tr>
Expand Down
Loading

0 comments on commit f29e77d

Please sign in to comment.