Tariff: add time-based grid fees (#29766)
This commit is contained in:
parent
4bc9ba4ca5
commit
908e6cf6f3
16 changed files with 345 additions and 53 deletions
|
|
@ -79,7 +79,13 @@
|
|||
:rows="textareaRows"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<PropertyZonesField v-else-if="zones" :id="id" v-model="value" :currency="currency" />
|
||||
<PropertyZonesField
|
||||
v-else-if="zones"
|
||||
:id="id"
|
||||
v-model="value"
|
||||
:currency="currency"
|
||||
:valueLabel="zonesValueLabel"
|
||||
/>
|
||||
<div v-else class="d-flex" :class="sizeClass">
|
||||
<div class="position-relative flex-grow-1">
|
||||
<input
|
||||
|
|
@ -276,6 +282,11 @@ export default {
|
|||
zones() {
|
||||
return this.type === "Zones";
|
||||
},
|
||||
zonesValueLabel() {
|
||||
return this.property === "chargesZones"
|
||||
? this.$t("config.tariff.zones.charge")
|
||||
: this.$t("config.tariff.zones.price");
|
||||
},
|
||||
pricePerKWh() {
|
||||
return this.type === "PricePerKWh";
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
<div class="border rounded p-3">
|
||||
<!-- Price input -->
|
||||
<div class="mb-3">
|
||||
<label :for="formId('price')" class="form-label">{{
|
||||
$t("config.tariff.zones.price")
|
||||
}}</label>
|
||||
<label :for="formId('price')" class="form-label">{{ valueLabel }}</label>
|
||||
<div class="d-flex w-50 w-min-200">
|
||||
<input
|
||||
:id="formId('price')"
|
||||
|
|
@ -123,6 +121,7 @@ export default {
|
|||
zone: { type: Object as PropType<Zone>, required: true },
|
||||
currency: { type: String as PropType<CURRENCY>, required: true },
|
||||
index: { type: Number, required: true },
|
||||
valueLabel: { type: String, required: true },
|
||||
},
|
||||
emits: ["update:zone", "save", "cancel"],
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
:zone="zone"
|
||||
:currency="currency"
|
||||
:index="index"
|
||||
:valueLabel="valueLabel"
|
||||
@save="saveEdit"
|
||||
@cancel="cancelEdit"
|
||||
/>
|
||||
|
|
@ -56,6 +57,7 @@ export default {
|
|||
default: () => [],
|
||||
},
|
||||
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
|
||||
valueLabel: { type: String, required: true },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -824,6 +824,7 @@
|
|||
"allMonths": "Alle Monate",
|
||||
"allTimes": "Alle Zeiten",
|
||||
"cancel": "Abbrechen",
|
||||
"charge": "Aufschlag",
|
||||
"days": "Tage",
|
||||
"edit": "Bearbeiten",
|
||||
"hours": "Stunden",
|
||||
|
|
|
|||
|
|
@ -824,6 +824,7 @@
|
|||
"allMonths": "All months",
|
||||
"allTimes": "All times",
|
||||
"cancel": "Cancel",
|
||||
"charge": "Charge",
|
||||
"days": "Days",
|
||||
"edit": "Edit",
|
||||
"hours": "Hours",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/plugin/golang/stdlib"
|
||||
"github.com/evcc-io/evcc/tariff/fixed"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
)
|
||||
|
||||
|
|
@ -14,10 +15,17 @@ type embed struct {
|
|||
Features_ []api.Feature `mapstructure:"features"`
|
||||
|
||||
Charges float64 `mapstructure:"charges"`
|
||||
ChargesZones_ []chargesZoneConfig `mapstructure:"chargesZones"`
|
||||
Tax float64 `mapstructure:"tax"`
|
||||
Formula string `mapstructure:"formula"`
|
||||
|
||||
calc func(float64, time.Time) (float64, error)
|
||||
chargesZones fixed.Zones
|
||||
calc func(price, charges float64, ts time.Time) (float64, error)
|
||||
}
|
||||
|
||||
type chargesZoneConfig struct {
|
||||
Charges float64
|
||||
Days, Hours, Months string
|
||||
}
|
||||
|
||||
func (t *embed) init() (err error) {
|
||||
|
|
@ -27,11 +35,21 @@ func (t *embed) init() (err error) {
|
|||
}
|
||||
}()
|
||||
|
||||
specs := make([]fixed.ZoneSpec, len(t.ChargesZones_))
|
||||
for i, c := range t.ChargesZones_ {
|
||||
specs[i] = fixed.ZoneSpec{Price: c.Charges, Days: c.Days, Hours: c.Hours, Months: c.Months}
|
||||
}
|
||||
zones, err := fixed.ParseZones(specs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.chargesZones = zones
|
||||
|
||||
if t.Formula == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.calc = func(price float64, ts time.Time) (float64, error) {
|
||||
t.calc = func(price, charges float64, ts time.Time) (float64, error) {
|
||||
vm := interp.New(interp.Options{})
|
||||
if err := vm.Use(stdlib.Symbols); err != nil {
|
||||
return 0, err
|
||||
|
|
@ -44,7 +62,7 @@ func (t *embed) init() (err error) {
|
|||
charges float64 = %f
|
||||
tax float64 = %f
|
||||
ts = time.Unix(%d, 0).Local()
|
||||
)`, price, t.Charges, t.Tax, ts.Unix())); err != nil {
|
||||
)`, price, charges, t.Tax, ts.Unix())); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
|
|
@ -61,17 +79,38 @@ func (t *embed) init() (err error) {
|
|||
}
|
||||
|
||||
// test the formula
|
||||
_, err = t.calc(0, time.Now())
|
||||
_, err = t.calc(0, t.Charges, time.Now())
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// effectiveCharges resolves the charge for ts in local time; later zones win.
|
||||
func (t *embed) effectiveCharges(ts time.Time) float64 {
|
||||
if len(t.chargesZones) == 0 {
|
||||
return t.Charges
|
||||
}
|
||||
|
||||
ts = ts.Local()
|
||||
day := fixed.Day(int(ts.Weekday()))
|
||||
month := fixed.Month(ts.Month() - 1)
|
||||
hm := fixed.HourMin{Hour: ts.Hour(), Min: ts.Minute()}
|
||||
|
||||
zones := t.chargesZones.ForDayAndMonth(day, month)
|
||||
for j := len(zones) - 1; j >= 0; j-- {
|
||||
if zones[j].Hours.Contains(hm) {
|
||||
return zones[j].Price
|
||||
}
|
||||
}
|
||||
return t.Charges
|
||||
}
|
||||
|
||||
func (t *embed) totalPrice(price float64, ts time.Time) float64 {
|
||||
charges := t.effectiveCharges(ts)
|
||||
if t.calc != nil {
|
||||
res, _ := t.calc(price, ts)
|
||||
res, _ := t.calc(price, charges, ts)
|
||||
return res
|
||||
}
|
||||
return (price + t.Charges) * (1 + t.Tax)
|
||||
return (price + charges) * (1 + t.Tax)
|
||||
}
|
||||
|
||||
var _ api.FeatureDescriber = (*embed)(nil)
|
||||
|
|
|
|||
136
tariff/embed_test.go
Normal file
136
tariff/embed_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package tariff
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTotalPriceFallback(t *testing.T) {
|
||||
e := embed{Charges: 0.10, Tax: 0.19}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
got := e.totalPrice(0.20, time.Now())
|
||||
assert.InDelta(t, (0.20+0.10)*1.19, got, 1e-9)
|
||||
}
|
||||
|
||||
func TestTotalPriceMatchingZone(t *testing.T) {
|
||||
e := embed{
|
||||
Charges: 0.50,
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: 0.10, Hours: "00:00-06:00"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
ts := time.Date(2026, 1, 15, 2, 0, 0, 0, time.Local)
|
||||
got := e.totalPrice(0.20, ts)
|
||||
assert.InDelta(t, 0.20+0.10, got, 1e-9)
|
||||
}
|
||||
|
||||
func TestTotalPriceNonMatchingZone(t *testing.T) {
|
||||
e := embed{
|
||||
Charges: 0.50,
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: 0.10, Hours: "00:00-06:00"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
ts := time.Date(2026, 1, 15, 12, 0, 0, 0, time.Local)
|
||||
got := e.totalPrice(0.20, ts)
|
||||
assert.InDelta(t, 0.20+0.50, got, 1e-9)
|
||||
}
|
||||
|
||||
func TestTotalPriceNegativeChargesZone(t *testing.T) {
|
||||
e := embed{
|
||||
Charges: 0.10,
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: -0.05, Hours: "10:00-12:00"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local)
|
||||
got := e.totalPrice(0.20, ts)
|
||||
assert.InDelta(t, 0.20-0.05, got, 1e-9)
|
||||
}
|
||||
|
||||
func TestTotalPriceLastZoneWins(t *testing.T) {
|
||||
e := embed{
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: 0.10, Hours: "00:00-06:00"},
|
||||
{Charges: 0.05, Hours: "02:00-04:00"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
// 03:00 is covered by both zones; later entry wins
|
||||
ts := time.Date(2026, 1, 15, 3, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, 0.20+0.05, e.totalPrice(0.20, ts), 1e-9)
|
||||
|
||||
// 05:00 is only covered by the broader zone
|
||||
ts = time.Date(2026, 1, 15, 5, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, 0.20+0.10, e.totalPrice(0.20, ts), 1e-9)
|
||||
}
|
||||
|
||||
func TestEffectiveChargesMonthFilter(t *testing.T) {
|
||||
e := embed{
|
||||
Charges: 0.20,
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: 0.05, Months: "Jan-Mar,Oct-Dec", Hours: "00:00-05:00"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
// February, in window: zone applies
|
||||
ts := time.Date(2026, 2, 15, 3, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, 0.05, e.effectiveCharges(ts), 1e-9)
|
||||
|
||||
// June, in hour window but month does not match: fallback
|
||||
ts = time.Date(2026, 6, 15, 3, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, 0.20, e.effectiveCharges(ts), 1e-9)
|
||||
}
|
||||
|
||||
func TestTotalPriceFormulaSeesResolvedCharges(t *testing.T) {
|
||||
e := embed{
|
||||
Charges: 0.10,
|
||||
ChargesZones_: []chargesZoneConfig{
|
||||
{Charges: 0.30, Hours: "10:00-12:00"},
|
||||
},
|
||||
Formula: "(price + charges) * 2",
|
||||
}
|
||||
require.NoError(t, e.init())
|
||||
|
||||
// In zone: charges resolves to 0.30
|
||||
ts := time.Date(2026, 1, 15, 11, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, (0.20+0.30)*2, e.totalPrice(0.20, ts), 1e-9)
|
||||
|
||||
// Out of zone: falls back to base 0.10
|
||||
ts = time.Date(2026, 1, 15, 14, 0, 0, 0, time.Local)
|
||||
assert.InDelta(t, (0.20+0.10)*2, e.totalPrice(0.20, ts), 1e-9)
|
||||
}
|
||||
|
||||
func TestEmbedDecodeChargesZones(t *testing.T) {
|
||||
other := map[string]any{
|
||||
"charges": 0.20,
|
||||
"chargesZones": []map[string]any{
|
||||
{"charges": 0.05, "months": "Jan-Mar", "hours": "00:00-05:00"},
|
||||
{"charges": 0.30, "hours": "18:00-21:00"},
|
||||
},
|
||||
}
|
||||
|
||||
var cc struct {
|
||||
embed `mapstructure:",squash"`
|
||||
}
|
||||
require.NoError(t, util.DecodeOther(other, &cc))
|
||||
require.NoError(t, cc.embed.init())
|
||||
|
||||
assert.Len(t, cc.ChargesZones_, 2)
|
||||
assert.InDelta(t, 0.05, cc.ChargesZones_[0].Charges, 1e-9)
|
||||
assert.Equal(t, "Jan-Mar", cc.ChargesZones_[0].Months)
|
||||
assert.Len(t, cc.chargesZones, 2)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
)
|
||||
|
||||
type Fixed struct {
|
||||
*embed
|
||||
clock clock.Clock
|
||||
zones fixed.Zones
|
||||
dynamic bool
|
||||
|
|
@ -26,55 +27,29 @@ func init() {
|
|||
|
||||
func NewFixedFromConfig(other map[string]any) (api.Tariff, error) {
|
||||
var cc struct {
|
||||
embed `mapstructure:",squash"`
|
||||
Price float64
|
||||
Zones []struct {
|
||||
Price float64
|
||||
Days, Hours, Months string
|
||||
}
|
||||
Zones []fixed.ZoneSpec
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cc.embed.init(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zones, err := fixed.ParseZones(cc.Zones)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &Fixed{
|
||||
embed: &cc.embed,
|
||||
clock: clock.New(),
|
||||
dynamic: len(cc.Zones) >= 1,
|
||||
}
|
||||
|
||||
for _, z := range cc.Zones {
|
||||
days, err := fixed.ParseDays(z.Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
months, err := fixed.ParseMonths(z.Months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours, err := fixed.ParseTimeRanges(z.Hours)
|
||||
if err != nil && z.Hours != "" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(hours) == 0 {
|
||||
t.zones = append(t.zones, fixed.Zone{
|
||||
Price: z.Price,
|
||||
Days: days,
|
||||
Months: months,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for _, h := range hours {
|
||||
t.zones = append(t.zones, fixed.Zone{
|
||||
Price: z.Price,
|
||||
Days: days,
|
||||
Months: months,
|
||||
Hours: h,
|
||||
})
|
||||
}
|
||||
dynamic: len(cc.Zones) >= 1 || len(cc.ChargesZones_) >= 1,
|
||||
zones: zones,
|
||||
}
|
||||
|
||||
sort.Sort(t.zones)
|
||||
|
|
@ -128,7 +103,7 @@ func (t *Fixed) Rates() (api.Rates, error) {
|
|||
rate := api.Rate{
|
||||
Start: ts,
|
||||
End: end,
|
||||
Value: zone.Price,
|
||||
Value: t.totalPrice(zone.Price, ts),
|
||||
}
|
||||
|
||||
res = append(res, rate)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,52 @@ type Zone struct {
|
|||
|
||||
type Zones []Zone
|
||||
|
||||
// ZoneSpec is the un-parsed config form of a zone (string day/hour/month fields).
|
||||
type ZoneSpec struct {
|
||||
Price float64
|
||||
Days, Hours, Months string
|
||||
}
|
||||
|
||||
// ParseZones expands ZoneSpecs into Zones, one per comma-separated hour range.
|
||||
func ParseZones(specs []ZoneSpec) (Zones, error) {
|
||||
var zones Zones
|
||||
for _, z := range specs {
|
||||
days, err := ParseDays(z.Days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
months, err := ParseMonths(z.Months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours, err := ParseTimeRanges(z.Hours)
|
||||
if err != nil && z.Hours != "" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(hours) == 0 {
|
||||
zones = append(zones, Zone{
|
||||
Price: z.Price,
|
||||
Days: days,
|
||||
Months: months,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for _, h := range hours {
|
||||
zones = append(zones, Zone{
|
||||
Price: z.Price,
|
||||
Days: days,
|
||||
Months: months,
|
||||
Hours: h,
|
||||
})
|
||||
}
|
||||
}
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// implement sort.Interface
|
||||
func (r Zones) Len() int {
|
||||
return len(r)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
|
||||
func TestFixed(t *testing.T) {
|
||||
tf := &Fixed{
|
||||
embed: &embed{},
|
||||
clock: clock.NewMock(),
|
||||
zones: []fixed.Zone{
|
||||
{Price: 0.3},
|
||||
|
|
|
|||
|
|
@ -40,9 +40,11 @@ params:
|
|||
advanced: true
|
||||
- name: interval
|
||||
deprecated: true
|
||||
- preset: tariff-base
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
{{ include "tariff-base" . }}
|
||||
forecast:
|
||||
source: js
|
||||
script: |
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ params:
|
|||
de: Wenn keine Zone zutrifft
|
||||
- name: zones
|
||||
required: true
|
||||
- preset: tariff-base
|
||||
render: |
|
||||
type: fixed
|
||||
price: {{ .price }}
|
||||
|
|
@ -36,3 +37,4 @@ render: |
|
|||
months: {{ .months }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ include "tariff-base" . }}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ params:
|
|||
description:
|
||||
en: Price
|
||||
de: Preis
|
||||
- preset: tariff-base
|
||||
render: |
|
||||
type: fixed
|
||||
price: {{ .price }}
|
||||
{{ include "tariff-base" . }}
|
||||
|
|
|
|||
|
|
@ -337,4 +337,55 @@ grid:
|
|||
await expect(tariffGrid).toBeVisible();
|
||||
await expect(tariffGrid).toContainText(["Forecast", "10.0 ct – 30.0 ct"].join(""));
|
||||
});
|
||||
|
||||
test("charges zones", async ({ page }) => {
|
||||
await start();
|
||||
await page.goto("/#/config");
|
||||
|
||||
const modal = page.getByTestId("tariff-modal");
|
||||
const tariffGrid = page.getByTestId("tariff-grid");
|
||||
const save = modal.getByRole("button", { name: "Validate & save" });
|
||||
|
||||
await page.getByRole("button", { name: "Add tariff" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Add grid import tariff" }).click();
|
||||
await modal.getByLabel("Provider").selectOption("Demo Market Price");
|
||||
|
||||
// expand advanced settings to reveal charges + chargesZones
|
||||
await modal.getByRole("button", { name: "Advanced" }).click();
|
||||
await expect(modal.getByText("Charges zones")).toBeVisible();
|
||||
|
||||
// base charge applies when no zone matches
|
||||
await modal.getByLabel("Charge").fill("20");
|
||||
|
||||
// chargesZones uses the "Charge" label override (not "Price"). Negative
|
||||
// values must be accepted to support reduced peak-hour grid fees.
|
||||
await modal.getByRole("button", { name: "Add zone" }).click();
|
||||
const zoneForm = modal.getByTestId("property-zone");
|
||||
await expect(zoneForm.getByLabel("Charge")).toBeVisible();
|
||||
await expect(zoneForm.getByLabel("Price")).not.toBeVisible();
|
||||
await zoneForm.getByLabel("Charge").fill("-2.5");
|
||||
await zoneForm.getByLabel("From", { exact: true }).fill("00:00");
|
||||
await zoneForm.getByLabel("To", { exact: true }).fill("06:00");
|
||||
await zoneForm.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
const zones = modal.getByTestId("property-zone");
|
||||
await expect(zones).toHaveCount(1);
|
||||
await expect(zones.first()).toContainText(["-2.5 ct", "00:00 – 06:00"].join(""));
|
||||
|
||||
// round-trip persistence
|
||||
await save.click();
|
||||
await expectModalHidden(modal);
|
||||
await expect(tariffGrid).toBeVisible();
|
||||
|
||||
await restart();
|
||||
await page.reload();
|
||||
|
||||
await tariffGrid.getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(modal);
|
||||
await modal.getByRole("button", { name: "Advanced" }).click();
|
||||
await expect(modal.getByLabel("Charge")).toHaveValue("20");
|
||||
await expect(zones).toHaveCount(1);
|
||||
await expect(zones.first()).toContainText(["-2.5 ct", "00:00 – 06:00"].join(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -578,6 +578,15 @@ presets:
|
|||
help:
|
||||
de: Zusätzlicher fester Aufschlag pro kWh
|
||||
en: Additional fixed charge per kWh
|
||||
- name: chargesZones
|
||||
type: zones
|
||||
advanced: true
|
||||
description:
|
||||
en: Charges zones
|
||||
de: Aufschlagszonen
|
||||
help:
|
||||
de: Zeitabhängige Aufschläge, die den Standardaufschlag überschreiben.
|
||||
en: Time-based grid fees that override the default charge.
|
||||
- name: tax
|
||||
type: float
|
||||
advanced: true
|
||||
|
|
|
|||
|
|
@ -2,6 +2,21 @@
|
|||
{{- if .charges }}
|
||||
charges: {{ .charges }}
|
||||
{{- end }}
|
||||
{{- if .chargesZones }}
|
||||
chargesZones:
|
||||
{{- range .chargesZones }}
|
||||
- charges: {{ .price }}
|
||||
{{- if .days }}
|
||||
days: {{ .days }}
|
||||
{{- end }}
|
||||
{{- if .hours }}
|
||||
hours: {{ .hours }}
|
||||
{{- end }}
|
||||
{{- if .months }}
|
||||
months: {{ .months }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .tax }}
|
||||
tax: {{ .tax }}
|
||||
{{- end }}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue