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:
parent
2dc5c5479f
commit
5c83bf8972
6 changed files with 154 additions and 82 deletions
62
models.py
62
models.py
|
|
@ -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):
|
||||||
|
|
|
||||||
13
services.py
13
services.py
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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,35 +122,66 @@
|
||||||
</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>
|
||||||
<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"
|
<div class="row q-mb-sm">
|
||||||
label="Image URL">
|
<div class="col">
|
||||||
<q-btn @click="addProductImage" dense flat icon="add"></q-btn></q-input>
|
<q-input filled dense v-model.number="productDialog.data.price" type="number"
|
||||||
|
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
|
||||||
<q-chip v-for="imageUrl in productDialog.data.images" :key="imageUrl" removable
|
:mask="stall.currency != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask></q-input>
|
||||||
@remove="removeProductImage(imageUrl)" color="primary" text-color="white">
|
</div>
|
||||||
<span v-text="imageUrl.split('/').pop()"></span>
|
<div class="col q-ml-md">
|
||||||
</q-chip>
|
<q-input filled dense v-model.number="productDialog.data.quantity" type="number" label="Quantity"></q-input>
|
||||||
<q-input filled dense v-model.number="productDialog.data.price" type="number"
|
</div>
|
||||||
:label="'Price (' + stall.currency + ') *'" :step="stall.currency != 'sat' ? '0.01' : '1'"
|
</div>
|
||||||
: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="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" 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>
|
||||||
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue