diff --git a/pass/app/Payment.js b/pass/app/Payment.js index bb7540faab48eceb48dd0eb622f78d91ee5a7c54..5f0c69f89864811ef794e5c3c375ad84f4dd86fb 100644 --- a/pass/app/Payment.js +++ b/pass/app/Payment.js @@ -67,7 +67,7 @@ class Payment { return Payments() .where("id", this.id) .where("receipt_id", null) - .update({ receipt_id: receipt_id }) + .update({ receipt_id: receipt_id }, ["*"]) .then((result) => { if (result && result.length === 1) { return new Payment(result[0]); @@ -172,6 +172,11 @@ class Payment { }); } + /** + * + * @param {String} payment_id + * @returns {Promise<Payment|never>} + */ static async LOAD_FROM_PUBLIC_ID(payment_id) { let matcher = payment_id.match(/^(A)?(\d+)$/); if (!matcher) { diff --git a/pass/app/Receipt.js b/pass/app/Receipt.js index 9b2b120815c8399b7e97dd162a5011ee717b25f1..056c9fcf0678aaa7da52bc9d86f52b504c675587 100644 --- a/pass/app/Receipt.js +++ b/pass/app/Receipt.js @@ -6,6 +6,7 @@ const config = require("config"); * @typedef {Object} ReceiptsDB * @property {number} id * @property {string} receipt + * @property {string} invoice_id * @property {number} payment_id * @property {string} created_at */ @@ -17,11 +18,8 @@ class Receipt { * * @param {Object} data * @param {number} data.id - * @param {string} data.company - * @param {string} data.name - * @param {string} data.email - * @param {string} data.address * @param {string} data.receipt + * @param {string} invoice_id * @param {number} data.payment_id * @param {string} data.created_at */ @@ -31,11 +29,8 @@ class Receipt { if (data.id < 0) { this.public_id = "TEST_INVOICE"; } - this.company = data.company; - this.name = data.name; - this.email = data.email; - this.address = data.address; this.receipt = data.receipt; + this.invoice_id = data.invoice_id; this.payment_id = data.payment_id; this.created_at = dayjs(data.created_at); } @@ -43,11 +38,8 @@ class Receipt { /** * @param {Object} data * @param {number} data.id - * @param {string} data.company - * @param {string} data.name - * @param {string} data.email - * @param {string} data.address * @param {string} [data.receipt] + * @param {string} data.invoice_id * @param {number} data.payment_id * @param {string} data.created_at * @@ -65,6 +57,20 @@ class Receipt { }); } + /** + * + * @param {int} private_payment_reference + * @returns {Promise<Receipt>} + */ + static async LOAD_FROM_PAYMENT_REFERENCE(private_payment_reference) { + return Receipts().where("payment_id", private_payment_reference).first().then(receipt => { + if (receipt == null) { + return Promise.reject("Receipt not yet generated"); + } + return new Receipt(receipt); + }); + } + /** * * @param {number} internal_id @@ -79,6 +85,21 @@ class Receipt { }) } + /** + * Loads the latest receipt for a given array of paymentreferences + * + * @param {array} payment_reference_ids + * @return {Promise<Receipt|null>} + */ + static async GET_LATEST_RECEIPT(payment_reference_ids) { + return __database_client("payments").whereIn("payment_reference_id", payment_reference_ids).rightJoin("receipts", "payments.receipt_id", "receipts.id").whereNotNull("receipts.invoice_id").orderBy("receipts.created_at", "desc").first().then(payment => { + if (payment == undefined) { + return null; + } + return new Receipt(payment); + }); + } + /** * * @param {string} receiptdata diff --git a/pass/config/default.json b/pass/config/default.json index fdd79f2a3af984e30c71ee736a6659690559074b..5b97ff35bbd3e51e97c7d895eb835cc77fc1685b 100644 --- a/pass/config/default.json +++ b/pass/config/default.json @@ -29,6 +29,11 @@ "url": "<ZAMMAD_URL>", "api_key": "<ZAMMAD_API_KEY>", "notification_ticket_id": "0" + }, + "invoiceninja": { + "url": "https://invoiceninja.com/", + "api_token": "<YOUR_API_TOKEN>", + "group_id": "<YOUR_CUSTOM_GROUP_ID>" } }, "price": { diff --git a/pass/database/migrations/20240913103116_invoiceninja.js b/pass/database/migrations/20240913103116_invoiceninja.js new file mode 100644 index 0000000000000000000000000000000000000000..0302d9dd3aa4632120591cd6b51dc04b8dfd5384 --- /dev/null +++ b/pass/database/migrations/20240913103116_invoiceninja.js @@ -0,0 +1,27 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise<void> } + */ +exports.up = function (knex) { + return knex.schema.alterTable("receipts", table => { + table.string("invoice_id"); + table.dropColumn("company"); + table.dropColumn("name"); + table.dropColumn("email"); + table.dropColumn("address"); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise<void> } + */ +exports.down = function (knex) { + return knex.schema.alterTable("receipts", table => { + table.dropColumn("invoice_id"); + table.string("company", 500); + table.string("name", 500).notNullable(); + table.string("email", 200).notNullable(); + table.text("address").notNullable(); + }); +}; diff --git a/pass/lang/de/invoice.json b/pass/lang/de/invoice.json index f54655e5d8937d1f8c4efdab9d9a751fea28ac43..31af0049bdcb1fc35b144319c04667edd4e75857 100644 --- a/pass/lang/de/invoice.json +++ b/pass/lang/de/invoice.json @@ -46,8 +46,8 @@ "label": "Anschrift", "placeholder": "Mustergasse 3 30159 Musterstadt Deutschland" }, - "submit": "Rechnung anfragen", + "submit": "Rechnung erstellen", "storage": "Wir sind rechtlich dazu verpflichtet, einmal ausgestellte Rechnungen <span class=\"bold\">10 Jahre</span> lang aufzubewahren. Da eine Rechnung auf Sie persönlich ausgestellt sein muss, enthält sie zwangsläufig personenbeziehbare Daten (Name, Anschrift, E-Mail).", "success": "Ihre Nachricht wurde uns zugestellt. Wir bearbeiten die Anfrage so schnell wie möglich und antworten an die hinterlegte E-Mail Adresse." } -} +} \ No newline at end of file diff --git a/pass/lang/de/order.json b/pass/lang/de/order.json index 4bdb98f98672da12fa39166dfc77351644710aea..ae5e71f03774e43f761cd06ec454e37792aabdb0 100644 --- a/pass/lang/de/order.json +++ b/pass/lang/de/order.json @@ -12,7 +12,7 @@ "thankyou": "Vielen Dank für Ihren Einkauf!", "actions": { "order-confirmation": "Auftragsbestätigung herunterladen", - "receipt": "Rechnung anfragen", + "receipt": "Rechnung erstellen", "download-receipt": "Rechnung herunterladen", "refund": "Erstattung anfragen" }, diff --git a/pass/lang/en/invoice.json b/pass/lang/en/invoice.json index 0d205cea55dfcfbe7084178017502ba634d8041f..d88cb84c5233318c5a32b0174d996bc37a89a87b 100644 --- a/pass/lang/en/invoice.json +++ b/pass/lang/en/invoice.json @@ -2,7 +2,7 @@ "title_order": "Order {{orderid}}", "title_invoice": "Receipt {{orderid}}", "author": "SUMA-EV - Association for Free Access to Knowledge", - "subject": "MetaGer key: token (x{amount}})", + "subject": "MetaGer Key: {{amount}} Token ({{payment_reference}})", "invoice": "Invoice", "payment_reference_id": "Payment ID", "order-confirmation": "Order confirmation", @@ -35,19 +35,36 @@ "label": "Company name (optional)", "placeholder": "Any company" }, - "name": { - "label": "Full name", - "placeholder": "John Sample" + "first_name": { + "label": "First Name", + "placeholder": "John" }, - "mail": { - "label": "E-mail" + "last_name": { + "label": "Last Name", + "placeholder": "Sample" }, - "address": { - "label": "Address", - "placeholder": "Any street 3 3015 Any city Germany" + "address1": { + "label": "Address 1", + "placeholder": "Main Street 1101" }, - "submit": "Invoice request", + "address2": { + "label": "Address 2 (optional)", + "placeholder": "App. 3" + }, + "zip": { + "label": "Postal Code", + "placeholder": "123456" + }, + "city": { + "label": "City", + "placeholder": "Example City" + }, + "state": { + "label": "State (optional)", + "placeholder": "Washington" + }, + "submit": "Create Invoice", "storage": "We are legally obliged to keep once issued invoices <span class=\"bold\">10 years</span> long. Since an invoice must be issued to you personally, it necessarily contains personal data (name, address, e-mail).", "success": "Your message has been delivered to us. We will process the request as soon as possible and reply to the email address on file." } -} +} \ No newline at end of file diff --git a/pass/lang/en/order.json b/pass/lang/en/order.json index 33a61736ad2b0181fcc2b5bd4a77220034bddb9e..20809ad64527758e8e371fabf4eda1f0a628b50c 100644 --- a/pass/lang/en/order.json +++ b/pass/lang/en/order.json @@ -12,7 +12,7 @@ "thankyou": "Thank you for your purchase!", "actions": { "order-confirmation": "Download order confirmation", - "receipt": "Receipt request", + "receipt": "Create Receipt", "download-receipt": "Download receipt", "refund": "Request refund" }, diff --git a/pass/public/styles/orders/order.less b/pass/public/styles/orders/order.less index 2725ac21a4ce92d2abb68045ce6c897e3019d97c..cce85201517f008415afec2135dbb2224a29fdec 100644 --- a/pass/public/styles/orders/order.less +++ b/pass/public/styles/orders/order.less @@ -1,56 +1,70 @@ #order { padding: 1rem 0; + .breadcrumbs { display: flex; gap: 0.5rem; padding: 0; list-style-type: disclosure-closed; font-size: clamp(0.7rem, 4vw, 1rem); + li { margin-left: 1rem; + &:first-child { margin-left: 0; list-style-type: none; } + a { color: #777; } } } + grid-area: content; + #order-details { display: grid; grid-template-columns: 1fr auto auto; text-align: right; padding: 2rem 0; - > div { + + >div { padding: 0.5rem; + &.heading { text-align: left; border-bottom: 1px solid var(--color-main); font-weight: bold; padding-bottom: 0.2rem; } + &:not(.heading) { padding: 0.5rem 0; } + &.sum { font-weight: bold; } + &.price { white-space: nowrap; } + &.count { text-align: center; } + &.exchanged { - > span { + >span { font-size: 0.8rem; } } } } - > h3 { + + >h3 { text-align: center; } @@ -60,6 +74,7 @@ grid-template-columns: 1fr auto auto; justify-items: end; gap: 0.5rem; + .button { font-size: 0.8rem; display: flex; @@ -68,16 +83,20 @@ line-height: 1; gap: 0.5rem; padding: 0.5rem; + img { height: 1rem; + &.order-receipt { filter: brightness(0) invert(1); } } } + @media (max-width: 842px) { grid-template-columns: 1fr; - > a.button { + + >a.button { width: 100%; padding: 1rem; } @@ -86,18 +105,20 @@ #invoice { #download-receipt { - > a.button { + >a.button { display: flex; margin: 0 auto; justify-content: center; align-items: center; gap: 0.5rem; padding: 0.5rem; - > img { + + >img { height: 1.5em; } } } + #invoice-form { #invoice-form-fields { display: grid; @@ -111,15 +132,24 @@ font-size: 0.7rem; place-content: center; text-align: center; - #email, + + #first_name, + #last_name, #name, - #company { + #company, + #address1, + #address2, + #zip, + #city, + #state, + #country { padding: 0.5rem; border-radius: 5px; font-size: 0.8rem; width: 13rem; text-align: center; } + #address { padding: 0.5rem; border-radius: 5px; @@ -127,21 +157,24 @@ text-align: center; width: 13rem; } + button { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; + img { height: 1.2rem; } } } } + .storage-time { font-size: 0.8rem; text-align: center; } } } -} +} \ No newline at end of file diff --git a/pass/routes/orders/receipt.js b/pass/routes/orders/receipt.js index 9246e14c257295282f80b29bc625e6b9fe50efcf..7758d3580f0299110bd1c526469e8de7eaed4d72 100644 --- a/pass/routes/orders/receipt.js +++ b/pass/routes/orders/receipt.js @@ -5,8 +5,10 @@ const config = require("config"); const { body, validationResult } = require("express-validator"); const Receipt = require("../../app/Receipt"); const Zammad = require("../../app/Zammad"); +const Payment = require("../../app/Payment"); +const { t } = require("i18next"); -router.get("/", (req, res) => { +router.get("/", async (req, res) => { req.data.order.invoice = { params: { name: req.query.name || "", @@ -19,22 +21,69 @@ router.get("/", (req, res) => { errors: {}, }; + let orders = req.data.key.key.get_charge_orders(); + let payment_reference_ids = []; + orders.forEach(order => { + payment_reference_ids.push(order.payment_reference_id); + }); + + let receipt = await Receipt.GET_LATEST_RECEIPT(payment_reference_ids); + if (receipt != null) { + await fetch(config.get("app.invoiceninja.url") + "/api/v1/invoices/" + receipt.invoice_id, { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }) + .then(response => response.json()) + .then(invoice => { + return fetch(config.get("app.invoiceninja.url") + "/api/v1/clients/" + invoice.data.client_id, { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }); + }).then(response => response.json()) + .then(client => { + req.data.order.invoice_old = { + company: client.data.name, + first_name: client.data.contacts[0].first_name, + last_name: client.data.contacts[0].last_name, + address1: client.data.address1, + address2: client.data.address2, + zip: client.data.postal_code, + city: client.data.city, + state: client.data.state + } + }).catch(reason => { }); + } + res.render("key", req.data); }); router.post( "/*", - body("email").isEmail({ domain_specific_validation: true }), - body("address").isLength({ max: 1000 }), - body("name").isLength({ max: 500 }), - body("company").isLength({ max: 500 }), + body("company").isLength({ max: 100 }), + body("first_name").isLength({ max: 50 }), + body("last_name").isLength({ max: 50 }), + body("address1").isLength({ max: 100 }), + body("address2").isLength({ max: 100 }), + body("zip").isLength({ max: 25 }), + body("city").isLength({ max: 25 }), + body("country").isLength({ max: 50 }), (req, res, next) => { req.data.order.invoice = { params: { company: req.body.company || "", - name: req.body.name || "", - email: req.body.email || "", - address: req.body["address"] || "", + first_name: req.body.first_name || "", + last_name: req.body.last_name || "", + address1: req.body.address1 || "", + address2: req.body.address2 || "", + zip: req.body.zip || "", + city: req.body.city || "", + state: req.body.state || "", }, errors: {}, }; @@ -51,7 +100,155 @@ router.post( } ); -router.post("/", (req, res) => { +router.post("/", async (req, res) => { + let order_id = req.data.order.payment.public_id; + let payment_reference = req.data.order.payment_reference.public_id; + + let url = req.data.links.order_actions_base + "/" + req.data.order.payment.public_id + "/receipt/download"; + + // Check if there already is a receipt + if (req.data.order.payment.receipt_id != null) { + res.redirect(url); + return; + } + + // Check if there is a previous client + let orders = req.data.key.key.get_charge_orders(); + let payment_reference_ids = []; + orders.forEach(order => { + payment_reference_ids.push(order.payment_reference_id); + }); + + let receipt = await Receipt.GET_LATEST_RECEIPT(payment_reference_ids); + let client_id = null; + if (receipt != null) { + await fetch(config.get("app.invoiceninja.url") + "/api/v1/invoices/" + receipt.invoice_id, { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }) + .then(response => response.json()) + .then(invoice => { + return fetch(config.get("app.invoiceninja.url") + "/api/v1/clients/" + invoice.data.client_id, { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }); + }).then(response => response.json()) + .then(client => { + client_id = client.data.id; + }).catch(reason => { }); + } + + // Create a new Client + let post_data = { + contacts: [{ + "first_name": req.data.order.invoice.params.first_name, + "last_name": req.data.order.invoice.params.last_name, + "send_email": true + }], + name: req.data.order.invoice.params.company.length > 0 ? req.data.order.invoice.params.company : null, + address1: req.data.order.invoice.params.address1, + address2: req.data.order.invoice.params.address2, + city: req.data.order.invoice.params.city, + state: req.data.order.invoice.params.state, + postal_code: req.data.order.invoice.params.zip, + country_code: "DE", + currency_code: "EUR", + group_settings_id: config.get("app.invoiceninja.group_id") + }; + + let create_or_update_promise = null; + if (client_id != null) { + // Update + create_or_update_promise = fetch(config.get("app.invoiceninja.url") + "/api/v1/clients/" + client_id, { + method: "PUT", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + body: JSON.stringify(post_data) + }); + } else { + create_or_update_promise = fetch(config.get("app.invoiceninja.url") + "/api/v1/clients", { + method: "POST", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + body: JSON.stringify(post_data) + }); + } + + + + return create_or_update_promise.then(response => response.json()).then(response => { + let client = response.data; + let assigned_user_id = client.contacts[0].id; + return fetch(config.get("app.invoiceninja.url") + "/api/v1/clients/" + client.id, { + method: "PUT", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + body: JSON.stringify({ + assigned_user_id: assigned_user_id, + contacts: client.contacts, + }) + }); + }).then(response => response.json()).then(response => { + let client = response.data; + Payment.LOAD_FROM_PUBLIC_ID(req.data.order.payment.public_id).then(payment => { + // Create the invoice + let actions = new URLSearchParams({ + send_email: "false", + mark_sent: "true", + amount_paid: payment.converted_price + }); + return fetch(config.get("app.invoiceninja.url") + "/api/v1/invoices?" + actions.toString(), { + method: "POST", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + body: JSON.stringify({ + client_id: client.id, + po_number: req.data.order.payment.public_id, + line_items: [{ + quantity: 1, + cost: req.data.order.payment_reference.price, + notes: t("subject", { + ns: "invoice", + amount: Math.round( + (payment.converted_price / req.data.order.payment_reference.price) * req.data.order.payment_reference.amount + ), + payment_reference: req.data.order.payment_reference.public_id + }), + public_notes: t("payment-received", { ns: "invoice" }), + tax_name1: "MwSt.", + tax_rate1: 7 + }] + }) + }) + .then(response => response.json()) + .then(invoice => { + // Return the new invoice as pdf + return Receipt.CREATE_NEW_RECEIPT({ + invoice_id: invoice.data.id, + payment_id: req.data.order.payment.id + }); + }).then(receipt => { + payment.setReceipt(receipt.id) + return res.redirect(req.data.links.order_actions_base + "/" + receipt.payment_id + "/receipt/download") + }); + }) + + }) + let moderation_params = { order: req.data.order.payment.public_id, payment_reference: req.data.order.payment_reference.public_id, @@ -60,6 +257,9 @@ router.post("/", (req, res) => { email: req.data.order.invoice.params.email, address: req.data.order.invoice.params.address, }; + + + req.data.order.invoice.params = moderation_params; req.data.order.invoice.moderation_url = `${res.locals.baseDir }/admin/payments/receipt?${new URLSearchParams( @@ -108,9 +308,43 @@ router.get("/download", (req, res) => { res.status(404).render("error"); } return Receipt.LOAD_RECEIPT_FROM_INTERNAL_ID(req.data.order.payment.receipt_id).then(receipt => { - res.type("pdf").header({ - "Content-Disposition": `inline; filename=${receipt.public_id}.pdf` - }).send(Buffer.from(receipt.receipt.toString(), "base64")); + + // Receipt can either be in the database itself, or via invoiceninja + if (receipt.invoice_id != null) { + // Load invoice + let invoice_number = null; + return fetch(config.get("app.invoiceninja.url") + "/api/v1/invoices/" + receipt.invoice_id, { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }) + .then(response => response.json()) + .then(invoice => { + invoice_number = invoice.data.number; + return fetch(config.get("app.invoiceninja.url") + "/api/v1/invoice/" + invoice.data.invitations[0].key + "/download", { + method: "GET", + headers: { + "X-API-TOKEN": config.get("app.invoiceninja.api_token"), + "Content-Type": "application/json" + }, + }) + }) + .then(response => response.blob()) + .then(blob => { + res.setHeader("Content-Length", blob.size); + res.setHeader("Content-Type", blob.type); + res.setHeader("Content-Disposition", `attachment; filename=receipt-metager-${invoice_number}.pdf`); + return blob.arrayBuffer().then(buf => { + res.send(Buffer.from(buf)); + }) + }); + } else { + res.type("pdf").header({ + "Content-Disposition": `inline; filename=${receipt.public_id}.pdf` + }).send(Buffer.from(receipt.receipt.toString(), "base64")); + } }).catch(reason => { console.error(reason); res.locals.error = { status: 404 }; diff --git a/pass/views/orders/invoice.ejs b/pass/views/orders/invoice.ejs index 02d5d9e0c9a8d4cb35b263afdaf5b355844609b0..e842e6912746dd7735b2c22298e94af14d86d046 100644 --- a/pass/views/orders/invoice.ejs +++ b/pass/views/orders/invoice.ejs @@ -13,19 +13,35 @@ <div id="invoice-form-fields"> <div class="invoice-form-field"> <label for="company" <%_ if(order.invoice.errors.company) { _%>class="error" <%_ } _%>><%= req.t("form.company.label", {ns: "invoice"}) _%></label> - <input type="text" name="company" id="company" placeholder="<%= req.t("form.company.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.company %>"> + <input type="text" name="company" id="company" placeholder="<%= req.t("form.company.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.company ?? req.data.order.invoice_old.company %>"> </div> <div class="invoice-form-field"> - <label for="name" <%_ if(order.invoice.errors.name) { _%>class="error" <%_ } _%>><%= req.t("form.name.label", {ns: "invoice"}) _%>*</label> - <input type="text" name="name" id="name" placeholder="<%= req.t("form.name.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.name %>" required> + <label for="first_name" <%_ if(order.invoice.errors.first_name) { _%>class="error" <%_ } _%>><%= req.t("form.first_name.label", {ns: "invoice"}) _%>*</label> + <input type="text" name="first_name" id="first_name" placeholder="<%= req.t("form.first_name.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.first_name ?? req.data.order.invoice_old.first_name %>" required> </div> <div class="invoice-form-field"> - <label for="email" <%_ if(order.invoice.errors.email) { _%>class="error" <%_ } _%>><%= req.t("form.mail.label", {ns: "invoice"}) _%>*</label> - <input type="email" name="email" id="email" placeholder="test@example.com" value="<%= order.invoice.params.email %>" required> + <label for="last_name" <%_ if(order.invoice.errors.last_name) { _%>class="error" <%_ } _%>><%= req.t("form.last_name.label", {ns: "invoice"}) _%>*</label> + <input type="text" name="last_name" id="last_name" placeholder="<%= req.t("form.last_name.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.last_name ?? req.data.order.invoice_old.last_name %>" required> </div> <div class="invoice-form-field"> - <label for="address" <%_ if(order.invoice.errors["address"]) { _%>class="error" <%_ } _%>><%= req.t("form.address.label", {ns: "invoice"}) _%>*</label> - <textarea name="address" id="address" cols="30" rows="3" placeholder="<%- req.t("form.address.placeholder", {ns: "invoice"}) _%>" required><%= order.invoice.params["address"] %></textarea> + <label for="address1" <%_ if(order.invoice.errors.address1) { _%>class="error" <%_ } _%>><%= req.t("form.address1.label", {ns: "invoice"}) _%>*</label> + <input type="text" name="address1" id="address1" placeholder="<%= req.t("form.address1.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.address1 ?? req.data.order.invoice_old.address1 %>" required> + </div> + <div class="invoice-form-field"> + <label for="address2" <%_ if(order.invoice.errors.address2) { _%>class="error" <%_ } _%>><%= req.t("form.address2.label", {ns: "invoice"}) _%></label> + <input type="text" name="address2" id="address2" placeholder="<%= req.t("form.address2.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.address2 ?? req.data.order.invoice_old.address2 %>"> + </div> + <div class="invoice-form-field"> + <label for="zip" <%_ if(order.invoice.errors.zip) { _%>class="error" <%_ } _%>><%= req.t("form.zip.label", {ns: "invoice"}) _%>*</label> + <input type="text" name="zip" id="zip" placeholder="<%= req.t("form.zip.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.zip ?? req.data.order.invoice_old.zip %>" required> + </div> + <div class="invoice-form-field"> + <label for="city" <%_ if(order.invoice.errors.city) { _%>class="error" <%_ } _%>><%= req.t("form.city.label", {ns: "invoice"}) _%>*</label> + <input type="text" name="city" id="city" placeholder="<%= req.t("form.city.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.city ?? req.data.order.invoice_old.city %>" required> + </div> + <div class="invoice-form-field"> + <label for="state" <%_ if(order.invoice.errors.state) { _%>class="error" <%_ } _%>><%= req.t("form.state.label", {ns: "invoice"}) _%></label> + <input type="text" name="state" id="state" placeholder="<%= req.t("form.state.placeholder", {ns: "invoice"}) _%>" value="<%= order.invoice.params.state ?? req.data.order.invoice_old.state %>"> </div> <div class="invoice-form-field"> <button type="submit" class="button">