Custom shipping cost (#86)

* 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
This commit is contained in:
Vlad Stan 2023-09-21 17:29:13 +03:00 committed by GitHub
parent 2dc5c5479f
commit 5c83bf8972
6 changed files with 154 additions and 82 deletions

View file

@ -215,11 +215,17 @@ class Stall(PartialStall, Nostrable):
######################################## PRODUCTS ######################################## ######################################## PRODUCTS ########################################
class ProductShippingCost(BaseModel):
id: str
cost: int
class ProductConfig(BaseModel): class ProductConfig(BaseModel):
description: Optional[str] description: Optional[str]
currency: Optional[str] currency: Optional[str]
use_autoreply: Optional[bool] = False use_autoreply: Optional[bool] = False
autoreply_message: Optional[str] autoreply_message: Optional[str]
shipping: Optional[List[ProductShippingCost]] = []
class PartialProduct(BaseModel): class PartialProduct(BaseModel):
@ -251,6 +257,7 @@ class Product(PartialProduct, Nostrable):
"currency": self.config.currency, "currency": self.config.currency,
"price": self.price, "price": self.price,
"quantity": self.quantity, "quantity": self.quantity,
"shipping": [dict(s) for s in self.config.shipping or []]
} }
categories = [["t", tag] for tag in self.categories] categories = [["t", tag] for tag in self.categories]
@ -358,24 +365,67 @@ class PartialOrder(BaseModel):
) )
async def costs_in_sats( 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]: ) -> Tuple[float, float]:
product_prices = {} product_prices = {}
for p in products: 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 product_cost: float = 0 # todo
for item in self.items: for item in self.items:
price = product_prices[item.product_id].price assert item.quantity > 0, "Quantity cannot be negative"
currency = product_prices[item.product_id].config.currency or "sat" price = product_prices[item.product_id]["price"]
currency = product_prices[item.product_id]["currency"]
if currency != "sat": if currency != "sat":
price = await fiat_amount_as_satoshis(price, currency) price = await fiat_amount_as_satoshis(price, currency)
product_cost += item.quantity * price product_cost += item.quantity * price
if currency != "sat": 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, shipping_cost 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 receipt
class Order(PartialOrder): class Order(PartialOrder):

View file

@ -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): if data.event_id and await get_order_by_event_id(merchant.id, data.event_id):
return None return None
order, invoice = await build_order_with_payment( order, invoice, receipt = await build_order_with_payment(
merchant.id, merchant.public_key, data merchant.id, merchant.public_key, data
) )
await create_order(merchant.id, order) await create_order(merchant.id, order)
return PaymentRequest( return PaymentRequest(
id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)] id=data.id, payment_options=[PaymentOption(type="ln", link=invoice)], message=receipt
) )
@ -90,7 +90,10 @@ async def build_order_with_payment(
assert shipping_zone, f"Shipping zone not found for order '{data.id}'" assert shipping_zone, f"Shipping zone not found for order '{data.id}'"
product_cost_sat, shipping_cost_sat = await data.costs_in_sats( 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) wallet_id = await get_wallet_for_product(data.items[0].product_id)
@ -126,7 +129,7 @@ async def build_order_with_payment(
extra=extra, extra=extra,
) )
return order, invoice return order, invoice, receipt
async def update_merchant_to_nostr( async def update_merchant_to_nostr(
@ -567,7 +570,7 @@ async def _handle_new_order(
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
payment_req = await create_new_failed_order( 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 = { response = {

View file

@ -102,20 +102,11 @@
</q-td> </q-td>
<q-td key="id" :props="props"> {{props.row.id}} </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="price" :props="props"> {{props.row.price}} </q-td>
<q-td key="quantity" :props="props"> <q-td key="quantity" :props="props">
{{props.row.quantity}} {{props.row.quantity}}
</q-td> </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> </q-tr>
</template> </template>
</q-table> </q-table>
@ -131,15 +122,35 @@
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
<q-dialog v-model="productDialog.showDialog" position="top"> <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-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.name" label="Name"></q-input>
<q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></q-input> <q-input filled dense v-model.trim="productDialog.data.config.description" label="Description"></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-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 <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" multiple hide-dropdown-icon input-debounce="0" new-value-mode="add-unique"
label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select> label="Categories (Hit Enter to add)" placeholder="crafts,robots,etc"></q-select>
</div>
</q-expansion-item>
<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" <q-input filled dense v-model.trim="productDialog.data.image" @keydown.enter="addProductImage" type="url"
label="Image URL"> label="Image URL">
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input> <q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
@ -148,18 +159,29 @@
@remove="removeProductImage(imageUrl)" color="primary" text-color="white"> @remove="removeProductImage(imageUrl)" color="primary" text-color="white">
<span v-text="imageUrl.split('/').pop()"></span> <span v-text="imageUrl.split('/').pop()"></span>
</q-chip> </q-chip>
<q-input filled dense v-model.number="productDialog.data.price" type="number" </div>
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'" </q-expansion-item>
: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" icon="settings" label="Advanced options"> <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>
<q-card-section> <q-card-section>
<div class="row q-mb-sm"> <div class="row q-mb-sm">
<div class="col"> <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>
</div> </div>

View file

@ -23,19 +23,7 @@ async function stallDetails(path) {
showDialog: false, showDialog: false,
showRestore: false, showRestore: false,
url: true, url: true,
data: { data: null
id: null,
name: '',
categories: [],
images: [],
image: null,
price: 0,
quantity: 0,
config: {
description: ''
}
}
}, },
productsFilter: '', productsFilter: '',
productsTable: { productsTable: {
@ -76,18 +64,6 @@ async function stallDetails(path) {
align: 'left', align: 'left',
label: 'Quantity', label: 'Quantity',
field: 'quantity' field: 'quantity'
},
{
name: 'categories',
align: 'left',
label: 'Categories',
field: 'categories'
},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
} }
], ],
pagination: { pagination: {
@ -112,6 +88,24 @@ async function stallDetails(path) {
) )
return stall 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 () { getStall: async function () {
try { try {
const { data } = await LNbits.api.request( const { data } = await LNbits.api.request(
@ -202,7 +196,7 @@ async function stallDetails(path) {
} }
}, },
sendProductFormData: function () { sendProductFormData: function () {
var data = { const data = {
stall_id: this.stall.id, stall_id: this.stall.id,
id: this.productDialog.data.id, id: this.productDialog.data.id,
name: this.productDialog.data.name, name: this.productDialog.data.name,
@ -265,7 +259,14 @@ async function stallDetails(path) {
} }
}, },
editProduct: async function (product) { editProduct: async function (product) {
const emptyShipping = this.newEmtpyProductData().config.shipping
this.productDialog.data = { ...product } 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 this.productDialog.showDialog = true
}, },
deleteProduct: async function (productId) { deleteProduct: async function (productId) {
@ -293,19 +294,7 @@ async function stallDetails(path) {
}) })
}, },
showNewProductDialog: async function (data) { showNewProductDialog: async function (data) {
this.productDialog.data = data || { this.productDialog.data = data || this.newEmtpyProductData()
id: null,
name: '',
description: '',
categories: [],
image: null,
images: [],
price: 0,
quantity: 0,
config: {
description: ''
}
}
this.productDialog.showDialog = true this.productDialog.showDialog = true
}, },
openSelectPendingProductDialog: async function () { openSelectPendingProductDialog: async function () {
@ -324,11 +313,16 @@ async function stallDetails(path) {
}, },
customerSelectedForOrder: function (customerPubkey) { customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey) this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = ''){
if (value.length <= 44) return value
return value.substring(0, 40) + '...'
} }
}, },
created: async function () { created: async function () {
await this.getStall() await this.getStall()
this.products = await this.getProducts() this.products = await this.getProducts()
this.productDialog.data = this.newEmtpyProductData()
} }
}) })
} }

View file

@ -34,15 +34,14 @@
:icon="props.row.expanded? 'remove' : 'add'" /> :icon="props.row.expanded? 'remove' : 'add'" />
</q-td> </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="currency" :props="props"> {{props.row.currency}} </q-td>
<q-td key="description" :props="props"> <q-td key="description" :props="props">
{{props.row.config.description}} {{shortLabel(props.row.config.description)}}
</q-td> </q-td>
<q-td key="shippingZones" :props="props"> <q-td key="shippingZones" :props="props">
<div> <div>
{{props.row.shipping_zones.filter(z => !!z.name).map(z => {{shortLabel(props.row.shipping_zones.filter(z => !!z.name).map(z => z.name).join(', '))}}
z.name).join(', ')}}
</div> </div>
</q-td> </q-td>
</q-tr> </q-tr>

View file

@ -251,6 +251,10 @@ async function stallList(path) {
}, },
customerSelectedForOrder: function (customerPubkey) { customerSelectedForOrder: function (customerPubkey) {
this.$emit('customer-selected-for-order', customerPubkey) this.$emit('customer-selected-for-order', customerPubkey)
},
shortLabel(value = ''){
if (value.length <= 64) return value
return value.substring(0, 60) + '...'
} }
}, },
created: async function () { created: async function () {