@teleporthq/teleport-plugin-next-workflows 0.44.5 → 0.44.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/__tests__/credential-columns-reply.test.ts +286 -0
  2. package/__tests__/invoice-paid-status.test.ts +382 -0
  3. package/__tests__/invoice-tender.test.ts +5 -3
  4. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  5. package/dist/cjs/api-route-generator.js +3 -2
  6. package/dist/cjs/api-route-generator.js.map +1 -1
  7. package/dist/cjs/credential-columns-code.d.ts +17 -0
  8. package/dist/cjs/credential-columns-code.d.ts.map +1 -0
  9. package/dist/cjs/credential-columns-code.js +61 -0
  10. package/dist/cjs/credential-columns-code.js.map +1 -0
  11. package/dist/cjs/data-api-route-generator.d.ts +1 -0
  12. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  13. package/dist/cjs/data-api-route-generator.js +13 -5
  14. package/dist/cjs/data-api-route-generator.js.map +1 -1
  15. package/dist/cjs/executor-generator.d.ts.map +1 -1
  16. package/dist/cjs/executor-generator.js +12 -2
  17. package/dist/cjs/executor-generator.js.map +1 -1
  18. package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
  19. package/dist/cjs/invoice/invoice-assembly-code.js +19 -2
  20. package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
  21. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  22. package/dist/cjs/invoice/invoice-html-code.js +100 -71
  23. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  24. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  25. package/dist/esm/api-route-generator.d.ts.map +1 -1
  26. package/dist/esm/api-route-generator.js +3 -2
  27. package/dist/esm/api-route-generator.js.map +1 -1
  28. package/dist/esm/credential-columns-code.d.ts +17 -0
  29. package/dist/esm/credential-columns-code.d.ts.map +1 -0
  30. package/dist/esm/credential-columns-code.js +57 -0
  31. package/dist/esm/credential-columns-code.js.map +1 -0
  32. package/dist/esm/data-api-route-generator.d.ts +1 -0
  33. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  34. package/dist/esm/data-api-route-generator.js +11 -3
  35. package/dist/esm/data-api-route-generator.js.map +1 -1
  36. package/dist/esm/executor-generator.d.ts.map +1 -1
  37. package/dist/esm/executor-generator.js +12 -2
  38. package/dist/esm/executor-generator.js.map +1 -1
  39. package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
  40. package/dist/esm/invoice/invoice-assembly-code.js +19 -2
  41. package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
  42. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  43. package/dist/esm/invoice/invoice-html-code.js +100 -71
  44. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  45. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +3 -3
  47. package/src/api-route-generator.ts +3 -2
  48. package/src/credential-columns-code.ts +59 -0
  49. package/src/data-api-route-generator.ts +11 -3
  50. package/src/executor-generator.ts +12 -2
  51. package/src/invoice/invoice-assembly-code.ts +19 -2
  52. package/src/invoice/invoice-html-code.ts +100 -71
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teleporthq/teleport-plugin-next-workflows",
3
- "version": "0.44.5",
3
+ "version": "0.44.7",
4
4
  "description": "A plugin for workflow code generation in Next.js projects.",
5
5
  "author": "teleportHQ",
6
6
  "license": "MIT",
@@ -66,8 +66,8 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "@babel/types": "^7.5.5",
69
- "@teleporthq/teleport-shared": "^0.44.5",
69
+ "@teleporthq/teleport-shared": "^0.44.7",
70
70
  "@teleporthq/teleport-types": "^0.44.0"
71
71
  },
72
- "gitHead": "f58fa8b4c3e69d550a3fc13f85245f61789d7d92"
72
+ "gitHead": "a279c1bd1e268a8b7302e2c4fafd45748a1d242a"
73
73
  }
@@ -527,11 +527,12 @@ function __isStreamingAI(node, config) {
527
527
  return config && config.streaming && __streamingAITypes.indexOf(node.type) >= 0;
528
528
  }
529
529
 
530
- // One node's result to the browser — never one kept on the server (see segmentReply).
530
+ // One node's result to the browser — never one kept on the server, and a data
531
+ // node's rows without the users table's credentials (see segmentReply).
531
532
  function __streamNodeResult(res, nodeId, result) {
532
533
  const kept = SEGMENT_CONFIG.nodes.find(function(nd) { return nd.id === nodeId; });
533
534
  if (kept && kept.config && kept.config.keepResultOnServer === true && !utils.isBranchingNodeType(kept.type)) return;
534
- res.write('data: ' + JSON.stringify({ type: 'node-result', nodeId: nodeId, result: result }) + '\\n\\n');
535
+ res.write('data: ' + JSON.stringify({ type: 'node-result', nodeId: nodeId, result: utils.withoutCredentialColumns(kept, result) }) + '\\n\\n');
535
536
  }
536
537
 
537
538
  function __collectSegBranchNodes(startId, excludeId) {
@@ -0,0 +1,59 @@
1
+ import { TableAccess } from '@teleporthq/teleport-shared'
2
+ import { DATA_NODE_TYPES } from './data-api-route-generator'
3
+
4
+ /**
5
+ * Runtime helpers that leave the auth users table's credential columns out of
6
+ * the rows a server segment answers the browser with — the columns
7
+ * `TableAccess.AUTH_CREDENTIAL_COLUMNS` names, the ones every browser read of
8
+ * the data route is already served without. A data node of a server segment
9
+ * reads through the data route as the app's own server code, so its rows come
10
+ * back whole, and the segment's reply (or a streamed node result) is the
11
+ * browser's copy of them.
12
+ *
13
+ * Only the results of the nodes that read rows (`DATA_NODE_TYPES`) are
14
+ * touched: all the credentials of a row of the users table, the unambiguous
15
+ * ones of a raw statement's row, whose table is not known. A row is the
16
+ * result itself (a create or update spreads its row into it), an object it
17
+ * holds, or an element of an array it holds; column values are not walked.
18
+ */
19
+ export const generateCredentialColumnsCode = (): string => `
20
+ var __CREDENTIAL_COLUMNS = ${JSON.stringify(TableAccess.AUTH_CREDENTIAL_COLUMNS)};
21
+ var __UNAMBIGUOUS_CREDENTIAL_COLUMNS = ${JSON.stringify(
22
+ TableAccess.UNAMBIGUOUS_CREDENTIAL_COLUMNS
23
+ )};
24
+ var __CREDENTIAL_TABLES = ${JSON.stringify(TableAccess.AUTH_CREDENTIAL_TABLES)};
25
+ var __ROW_NODE_TYPES = ${JSON.stringify(Array.from(DATA_NODE_TYPES))};
26
+
27
+ function credentialColumnsOf(node) {
28
+ if (!node || __ROW_NODE_TYPES.indexOf(node.type) === -1) return [];
29
+ var tableName = node.config && typeof node.config.tableName === 'string' ? node.config.tableName : '';
30
+ if (!tableName) return __UNAMBIGUOUS_CREDENTIAL_COLUMNS;
31
+ var parts = tableName.trim().toLowerCase().replace(/"/g, '').split('.');
32
+ return __CREDENTIAL_TABLES.indexOf(parts[parts.length - 1]) !== -1 ? __CREDENTIAL_COLUMNS : [];
33
+ }
34
+
35
+ function withoutColumns(row, columns) {
36
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return row;
37
+ var kept = {};
38
+ Object.keys(row).forEach(function(key) {
39
+ if (columns.indexOf(String(key).toLowerCase()) === -1) kept[key] = row[key];
40
+ });
41
+ return kept;
42
+ }
43
+
44
+ function withoutRowsColumns(value, columns) {
45
+ if (Array.isArray(value)) return value.map(function(row) { return withoutColumns(row, columns); });
46
+ return withoutColumns(value, columns);
47
+ }
48
+
49
+ function withoutCredentialColumns(node, result) {
50
+ var columns = credentialColumnsOf(node);
51
+ if (columns.length === 0 || !result || typeof result !== 'object') return result;
52
+ if (Array.isArray(result)) return withoutRowsColumns(result, columns);
53
+ var kept = withoutColumns(result, columns);
54
+ Object.keys(kept).forEach(function(key) {
55
+ if (kept[key] && typeof kept[key] === 'object') kept[key] = withoutRowsColumns(kept[key], columns);
56
+ });
57
+ return kept;
58
+ }
59
+ `
@@ -15,7 +15,7 @@ import { generateSqlValidatorCode } from './sql-validator'
15
15
  import { generateCommonJsSessionTokenResolverCode } from './session-cookie-resolver'
16
16
  import { ContentTables, TableAccess } from '@teleporthq/teleport-shared'
17
17
 
18
- const DATA_NODE_TYPES = new Set([
18
+ export const DATA_NODE_TYPES: ReadonlySet<string> = new Set([
19
19
  'data-select',
20
20
  'data-create-item',
21
21
  'data-update-item',
@@ -677,6 +677,13 @@ function dropMissingOptionalColumns(tableName, entries, colTypes) {
677
677
  return kept;
678
678
  }
679
679
 
680
+ // The row a create or update answers with, whoever asked, without the auth
681
+ // users table's credentials (the columns a browser read is served without):
682
+ // a server-side caller hands that answer on to the browser as its result.
683
+ function withoutCredentialColumns(row, tableName) {
684
+ return row ? __taWithoutCredentials([row], tableName)[0] : row;
685
+ }
686
+
680
687
  async function handleCreate(client, body) {
681
688
  var tableName = body.tableName;
682
689
  var columnMappings = body.columnMappings || {};
@@ -751,7 +758,7 @@ async function handleCreate(client, body) {
751
758
  var item = result.rows && result.rows[0] ? result.rows[0] : null;
752
759
  var idIdx = columns.indexOf('id');
753
760
  var resolvedId = item ? (item.id || null) : (idIdx >= 0 ? values[idIdx] : null);
754
- return { item: item, id: resolvedId };
761
+ return { item: withoutCredentialColumns(item, tableName), id: resolvedId };
755
762
  }
756
763
 
757
764
  async function handleUpdate(client, body) {
@@ -803,7 +810,8 @@ async function handleUpdate(client, body) {
803
810
  sql += ' RETURNING *';
804
811
 
805
812
  var result = await safeQuery(client, sql, queryParams, 'update');
806
- return { updatedCount: result.rowCount || 0, item: result.rows[0] || null, id: (result.rows[0] && result.rows[0].id) || null };
813
+ var updated = result.rows[0] || null;
814
+ return { updatedCount: result.rowCount || 0, item: withoutCredentialColumns(updated, tableName), id: (updated && updated.id) || null };
807
815
  }
808
816
 
809
817
  async function handleDelete(client, body) {
@@ -4,6 +4,7 @@ import {
4
4
  generateWorkflowLocaleHelpersCode,
5
5
  } from './email-locale'
6
6
  import { generateSignedInDestinationCode } from './auth-return/signed-in-destination-code'
7
+ import { generateCredentialColumnsCode } from './credential-columns-code'
7
8
 
8
9
  export interface SharedRuntimeUtilsOptions {
9
10
  /**
@@ -1414,6 +1415,10 @@ var SEGMENT_REPLY_PRIVATE_KEYS = {
1414
1415
  // nodes after it in the same segment and never leaves the server (a statement's
1415
1416
  // rows read only to build what the browser may see, a sender's recipients).
1416
1417
  // Nothing in a later segment can read it.
1418
+ //
1419
+ // A data node's rows are replied without the auth users table's credentials
1420
+ // (see credential-columns-code.ts).
1421
+ ${generateCredentialColumnsCode()}
1417
1422
  // The nodes whose result the client branches on: never kept on the server.
1418
1423
  function isBranchingNodeType(type) {
1419
1424
  return type === 'general-if-statement' || type === 'general-switch';
@@ -1425,7 +1430,7 @@ function segmentReply(segmentConfig, context, snapshot) {
1425
1430
  var kept = {};
1426
1431
  var nodes = (segmentConfig && segmentConfig.nodes) || [];
1427
1432
  for (var ni = 0; ni < nodes.length; ni++) {
1428
- own[nodes[ni].id] = true;
1433
+ own[nodes[ni].id] = nodes[ni];
1429
1434
  if (nodes[ni].config && nodes[ni].config.keepResultOnServer === true && !isBranchingNodeType(nodes[ni].type)) {
1430
1435
  kept[nodes[ni].id] = true;
1431
1436
  }
@@ -1436,7 +1441,11 @@ function segmentReply(segmentConfig, context, snapshot) {
1436
1441
  if (SEGMENT_REPLY_PRIVATE_KEYS[key] || kept[key]) continue;
1437
1442
  var value = context[key];
1438
1443
  if (value === undefined) continue;
1439
- if (own[key] || !snapshot || !Object.prototype.hasOwnProperty.call(snapshot, key)) {
1444
+ if (own[key]) {
1445
+ reply[key] = withoutCredentialColumns(own[key], value);
1446
+ continue;
1447
+ }
1448
+ if (!snapshot || !Object.prototype.hasOwnProperty.call(snapshot, key)) {
1440
1449
  reply[key] = value;
1441
1450
  continue;
1442
1451
  }
@@ -1504,6 +1513,7 @@ module.exports = {
1504
1513
  loopIterationContext,
1505
1514
  snapshotIncomingContext,
1506
1515
  segmentReply,
1516
+ withoutCredentialColumns,
1507
1517
  evaluateCondition,
1508
1518
  evaluateSingleComparison,
1509
1519
  normalizeComparisonOperator,
@@ -93,6 +93,16 @@ var CURRENCY_SYMBOLS = {
93
93
  'CLP': 'CLP$', 'PEN': 'S/.', 'VND': '\\u20AB', 'UAH': '\\u20B4',
94
94
  };
95
95
 
96
+ // \`teleport_orders.payment_status\` values that mean the order's money was
97
+ // collected — the editor's PAID_PAYMENT_STATUS_VALUES (teleport-gui
98
+ // e-commerce/constants/order-statuses.ts), provider spellings included.
99
+ var PAID_ORDER_PAYMENT_STATUSES = ['paid', 'partially_refunded', 'succeeded', 'success', 'completed', 'captured'];
100
+
101
+ function isPaidOrder(order) {
102
+ var paymentStatus = String((order && order.payment_status) || '').trim().toLowerCase();
103
+ return PAID_ORDER_PAYMENT_STATUSES.indexOf(paymentStatus) !== -1;
104
+ }
105
+
96
106
  function isLoopbackHost(host) {
97
107
  var name = String(host || '').replace(/:[0-9]+$/, '').toLowerCase();
98
108
  return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
@@ -473,15 +483,22 @@ function assembleInvoiceData(body, hydratedOrder, hydratedItems) {
473
483
  var fallbackPaymentIntentId = orderRow.payment_intent_id || '';
474
484
  var fallbackNotes = orderRow.notes || '';
475
485
 
486
+ // Paid or not, when the caller does not say: the order's money state. The
487
+ // payment webhook marks an order paid and then asks for its invoice with just
488
+ // the order id; a cash-on-delivery order is invoiced unpaid when it is placed.
489
+ // The orders table keeps no payment time of its own — the mark-paid write
490
+ // stamps \`updated_at\`, just before the webhook asks.
491
+ var paidByOrder = !body.status && isPaidOrder(orderRow);
492
+
476
493
  var invoiceData = {
477
494
  id: body.id || require('crypto').randomUUID(),
478
495
  // Numbered by the route: an invoice issued at payment takes the next
479
496
  // number, a regenerated one keeps its own.
480
497
  invoiceNumber: '',
481
- status: body.status || 'issued',
498
+ status: body.status || (paidByOrder ? 'paid' : 'issued'),
482
499
  issueDate: issueDate,
483
500
  dueDate: dueDate,
484
- paidAt: body.paidAt || null,
501
+ paidAt: body.paidAt || (paidByOrder ? orderRow.updated_at || new Date().toISOString() : null),
485
502
  customerName: body.customerName || fallbackCustomerName || '',
486
503
  customerEmail: body.customerEmail || fallbackCustomerEmail || '',
487
504
  customerAddress: body.customerAddress || fallbackCustomerAddress || '',
@@ -12,12 +12,12 @@ import { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
12
12
  //
13
13
  // The UIDL tree has several features that need runtime handling:
14
14
  //
15
- // 1. `<context>` elements carry default invoice data on their attributes
16
- // (Company, Invoice, Customer, Products, Payment). Children reference
17
- // that data through expressions like `invoiceData?.Company?.name`. We
18
- // build a per-render scope from the attrs merged with real DB data
19
- // (real wins unless empty) so every expression resolves against the
20
- // buyer's actual order.
15
+ // 1. `<context>` elements carry the design canvas's sample invoice on
16
+ // their attributes (Company, Invoice, Customer, Products, Payment).
17
+ // Children reference that data through expressions like
18
+ // `invoiceData?.Company?.name`. Every render builds the scope from the
19
+ // real invoice alone, so every expression resolves against the buyer's
20
+ // actual order and an empty field prints nothing — never the sample.
21
21
  //
22
22
  // 2. `cms-list-repeater` iterates `invoiceData.Products` with a
23
23
  // per-iteration variable (the `renderPropIdentifier`). Each line item
@@ -153,11 +153,12 @@ var COMPONENT_DEFINITIONS = ${componentDefinitionsJson};
153
153
  var MAX_COMPONENT_DEPTH = 12;
154
154
 
155
155
  // ---------------------------------------------------------------------------
156
- // Scope construction — merges real DB data with defaults the user set on
157
- // the <context> element in the editor. Per-field fallback: if the real
158
- // field is empty/null, the UIDL default wins. Covers the common dev case
159
- // where COMPANY_DETAILS from invoiceSettings isn't filled in but the user
160
- // dropped "Acme Corporation" into the template preview.
156
+ // Scope construction — the real invoice, the store's company details and the
157
+ // buyer, never the sample on the template's <context>: an empty notes field,
158
+ // VAT number or paid date printed the design canvas's "Payment is due within
159
+ // 30 days", "US123456789" or "Jan 20, 2025" on a real invoice. The one value
160
+ // read from the context is the company logo URL, which the editor writes there
161
+ // from the store's own logo (the sample's is empty).
161
162
  // ---------------------------------------------------------------------------
162
163
 
163
164
  function extractStaticAttrsFromContext(uidl) {
@@ -194,23 +195,9 @@ function findFirstContextNode(node) {
194
195
  return null;
195
196
  }
196
197
 
197
- function nonEmpty(v) {
198
- if (v == null) return false;
199
- if (typeof v === 'string') return v.length > 0;
200
- if (typeof v === 'number') return !isNaN(v);
201
- return true;
202
- }
203
-
204
- function mergeWithDefaults(primary, defaults) {
205
- var out = Object.assign({}, defaults || {});
206
- if (primary) {
207
- Object.keys(primary).forEach(function (k) {
208
- if (nonEmpty(primary[k])) out[k] = primary[k];
209
- });
210
- }
211
- return out;
212
- }
213
-
198
+ // Money on the invoice, in the PDF and the email alike. A symbol that ends in
199
+ // a letter is a word ("lei", "CHF", "kr") and stands apart from the number
200
+ // ("lei 119.00"); a sign sits against it ("$119.00", "\u20AC119.00").
214
201
  function formatCurrencyValue(amount, sym, decimals) {
215
202
  var num = Number(amount);
216
203
  if (!isFinite(num)) num = 0;
@@ -218,7 +205,17 @@ function formatCurrencyValue(amount, sym, decimals) {
218
205
  var fixed = num.toFixed(dec);
219
206
  var parts = fixed.split('.');
220
207
  parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
221
- return (sym || '') + parts.join('.');
208
+ var symbol = String(sym == null ? '' : sym).trim();
209
+ var separator = /\\p{L}$/u.test(symbol) ? ' ' : '';
210
+ return symbol + separator + parts.join('.');
211
+ }
212
+
213
+ // Invoice statuses of money that was collected: a refund later moves a paid
214
+ // invoice to one of the last two, and it was paid all the same.
215
+ var SETTLED_INVOICE_STATUSES = ['paid', 'partially_refunded', 'refunded'];
216
+
217
+ function isSettledInvoice(status) {
218
+ return SETTLED_INVOICE_STATUSES.indexOf(String(status || '').toLowerCase()) !== -1;
222
219
  }
223
220
 
224
221
  // Per-line VAT formulas. These MIRROR the GUI helpers in
@@ -269,7 +266,7 @@ function formatDateValue(raw) {
269
266
  }
270
267
 
271
268
  function buildInvoiceDataScope(invoiceData) {
272
- var defaults = extractStaticAttrsFromContext(TEMPLATE_COMPONENT_UIDL);
269
+ var contextCompany = extractStaticAttrsFromContext(TEMPLATE_COMPONENT_UIDL).Company || {};
273
270
  var sym = invoiceData.currencySymbol || '$';
274
271
 
275
272
  var realCompany = {
@@ -283,7 +280,7 @@ function buildInvoiceDataScope(invoiceData) {
283
280
  registrationNumber: COMPANY_DETAILS.companyRegNumber,
284
281
  email: COMPANY_DETAILS.companyEmail,
285
282
  phone: COMPANY_DETAILS.companyPhone,
286
- logoUrl: invoiceData.companyLogoUrl || '',
283
+ logoUrl: invoiceData.companyLogoUrl || contextCompany.logoUrl || '',
287
284
  website: COMPANY_DETAILS.companyWebsite,
288
285
  };
289
286
 
@@ -319,12 +316,20 @@ function buildInvoiceDataScope(invoiceData) {
319
316
  amountDueNumber = Math.max(0, totalNumber - giftCardAmountNumber);
320
317
  }
321
318
 
319
+ // A paid invoice asks for nothing: where the due date would print, it says
320
+ // when it was paid. The order keeps no payment method finer than its
321
+ // provider, so the line names none.
322
+ var paid = isSettledInvoice(invoiceData.status);
323
+ var paidDate = formatDateValue(invoiceData.paidAt || (paid ? invoiceData.issueDate : ''));
324
+ var paidLine = paid ? 'Paid' + (paidDate ? ' on ' + paidDate : '') : '';
325
+
322
326
  var realInvoice = {
323
327
  invoiceNumber: invoiceData.invoiceNumber ? '#' + invoiceData.invoiceNumber : '',
324
328
  status: invoiceData.status || '',
325
329
  issueDate: formatDateValue(invoiceData.issueDate),
326
- dueDate: formatDateValue(invoiceData.dueDate),
327
- paidDate: formatDateValue(invoiceData.paidAt),
330
+ dueDate: paidLine || formatDateValue(invoiceData.dueDate),
331
+ paidDate: paidDate,
332
+ paidLine: paidLine,
328
333
  subtotal: formatCurrencyValue(invoiceData.subtotal, sym),
329
334
  taxRate: invoiceData.taxRate != null ? String(invoiceData.taxRate) : '',
330
335
  taxAmount: formatCurrencyValue(invoiceData.taxAmount, sym),
@@ -351,13 +356,6 @@ function buildInvoiceDataScope(invoiceData) {
351
356
  notes: invoiceData.notes || '',
352
357
  pdfUrl: invoiceData.pdfUrl || '',
353
358
  };
354
- // The merge below lets an EMPTY real value fall back to the template's
355
- // sample — right for a company address, wrong for a code or a card tail: an
356
- // order without a voucher must not print the designer's "SAVE20".
357
- var realInvoiceNeverSampled = { voucherCode: voucherCode, giftCardLast4: giftCardLast4 };
358
- // The buyer and their payment are never sampled either: an order with no
359
- // city (a booking, a pickup, a download) must not print the designer's
360
- // sample customer's, nor a card it was not paid with.
361
359
 
362
360
  var realCustomer = {
363
361
  name: invoiceData.customerName || '',
@@ -470,11 +468,11 @@ function buildInvoiceDataScope(invoiceData) {
470
468
 
471
469
  return {
472
470
  invoiceData: {
473
- Company: mergeWithDefaults(realCompany, defaults.Company),
474
- Invoice: Object.assign(mergeWithDefaults(realInvoice, defaults.Invoice), realInvoiceNeverSampled),
471
+ Company: realCompany,
472
+ Invoice: realInvoice,
475
473
  Customer: realCustomer,
476
474
  Payment: realPayment,
477
- Products: realProducts.length > 0 ? realProducts : (defaults.Products || []),
475
+ Products: realProducts,
478
476
  },
479
477
  };
480
478
  }
@@ -975,7 +973,12 @@ function buildFallbackInvoiceHtml(scope) {
975
973
  var parts = [];
976
974
  parts.push('<div style="padding:32px;font-family:Helvetica,Arial,sans-serif;color:#333;font-size:10pt;">');
977
975
  parts.push('<h1 style="text-align:right;font-size:24pt;margin:0 0 4px;">INVOICE</h1>');
978
- parts.push('<p style="text-align:right;color:#666;margin:0 0 24px;">' + escapeHtml(inv.invoiceNumber || '') + '</p>');
976
+ if (inv.paidLine) {
977
+ parts.push('<p style="text-align:right;color:#666;margin:0 0 4px;">' + escapeHtml(inv.invoiceNumber || '') + '</p>');
978
+ parts.push('<p style="text-align:right;color:#666;margin:0 0 24px;">' + escapeHtml(inv.paidLine) + '</p>');
979
+ } else {
980
+ parts.push('<p style="text-align:right;color:#666;margin:0 0 24px;">' + escapeHtml(inv.invoiceNumber || '') + '</p>');
981
+ }
979
982
  parts.push('<div style="display:flex;gap:40px;margin-bottom:24px;">');
980
983
  parts.push('<div style="flex:1;"><strong>' + escapeHtml(comp.name || '') + '</strong><br/>' +
981
984
  escapeHtml(comp.address || '') + '<br/>' +
@@ -1074,25 +1077,47 @@ function resolveTextSpans(spans, scope) {
1074
1077
  }).join('');
1075
1078
  }
1076
1079
 
1077
- // Amount tokens whose line means nothing at zero: "Delivery $0.00" on a
1078
- // pickup order, "Discount −$0.00" without a voucher. The table row (or list
1079
- // item / paragraph) that carries such a token is dropped before the tokens are
1080
- // filled, so the merchant's summary only lists the figures that applied.
1081
- // Matched innermost — a row inside a row keeps its parent.
1082
- var OPTIONAL_AMOUNT_TOKENS = ['shippingAmount', 'discountAmount', 'giftCardAmount'];
1083
-
1084
- function stripZeroAmountLines(template, inv) {
1085
- var out = template;
1086
- for (var i = 0; i < OPTIONAL_AMOUNT_TOKENS.length; i++) {
1087
- var key = OPTIONAL_AMOUNT_TOKENS[i];
1088
- if (Number(inv[key] || 0) > 0) continue;
1089
- var line = new RegExp(
1090
- '<(tr|li|p)\\\\b(?:(?!<\\\\/?\\\\1\\\\b)[\\\\s\\\\S])*?\\\\{\\\\{' + key + '\\\\}\\\\}(?:(?!<\\\\/?\\\\1\\\\b)[\\\\s\\\\S])*?<\\\\/\\\\1\\\\s*>',
1091
- 'g'
1092
- );
1093
- out = out.replace(line, '');
1094
- }
1095
- return out;
1080
+ // The summary lines the email leaves out. An amount whose line means nothing at
1081
+ // zero: "Delivery $0.00" on a pickup order, "Discount −$0.00" without a
1082
+ // voucher, "Tax $0.00" in an untaxed store. And what a paid invoice no longer
1083
+ // asks for: its due date and the amount due. As on the PDF, the amount due is
1084
+ // printed only beside a gift card; without one it is the total again.
1085
+ var OPTIONAL_AMOUNT_TOKENS = ['shippingAmount', 'discountAmount', 'taxAmount', 'giftCardAmount'];
1086
+
1087
+ function omittedLineTokens(inv) {
1088
+ var tokens = OPTIONAL_AMOUNT_TOKENS.filter(function (key) {
1089
+ return !(Number(inv[key] || 0) > 0);
1090
+ });
1091
+ var paid = isSettledInvoice(inv.status);
1092
+ if (paid) tokens.push('dueDate');
1093
+ if (paid || !(Number(inv.giftCardAmount || 0) > 0)) tokens.push('amountDue');
1094
+ return tokens;
1095
+ }
1096
+
1097
+ // A line is the table row, list item or paragraph carrying the token, matched
1098
+ // innermost — a row inside a row keeps its parent. The editor's designs publish
1099
+ // each line as a <div> of <span>s instead, so a token outside those drops the
1100
+ // innermost <div> holding it, and only when that <div> is the line alone: one
1101
+ // that also holds a block or another token is the merchant's own, and stays.
1102
+ var NO_BLOCK_OR_TOKEN = '(?!<\\\\/?(?:div|p|tr|li|table|ul|ol|h[1-6])\\\\b|\\\\{\\\\{)';
1103
+
1104
+ function stripLinesHolding(template, key) {
1105
+ var token = '\\\\{\\\\{' + key + '\\\\}\\\\}';
1106
+ var row = new RegExp(
1107
+ '<(tr|li|p)\\\\b(?:(?!<\\\\/?\\\\1\\\\b)[\\\\s\\\\S])*?' + token + '(?:(?!<\\\\/?\\\\1\\\\b)[\\\\s\\\\S])*?<\\\\/\\\\1\\\\s*>',
1108
+ 'g'
1109
+ );
1110
+ var lineDiv = new RegExp(
1111
+ '<div\\\\b(?:' + NO_BLOCK_OR_TOKEN + '[\\\\s\\\\S])*?' + token + '(?:' + NO_BLOCK_OR_TOKEN + '[\\\\s\\\\S])*?<\\\\/div\\\\s*>',
1112
+ 'g'
1113
+ );
1114
+ return template.replace(row, '').replace(lineDiv, '');
1115
+ }
1116
+
1117
+ function stripOmittedLines(template, inv) {
1118
+ return omittedLineTokens(inv).reduce(function (out, key) {
1119
+ return stripLinesHolding(out, key);
1120
+ }, template);
1096
1121
  }
1097
1122
 
1098
1123
  function replacePlaceholders(template, data) {
@@ -1100,7 +1125,8 @@ function replacePlaceholders(template, data) {
1100
1125
  var inv = data.invoice || {};
1101
1126
  var cust = data.customer || {};
1102
1127
  var comp = data.company || {};
1103
- return stripZeroAmountLines(template, inv).replace(/\\{\\{(\\w+)\\}\\}/g, function (match, key) {
1128
+ var sym = inv.currencySymbol;
1129
+ return stripOmittedLines(template, inv).replace(/\\{\\{(\\w+)\\}\\}/g, function (match, key) {
1104
1130
  var map = {
1105
1131
  invoiceNumber: inv.number || '',
1106
1132
  customerName: cust.name || '',
@@ -1111,14 +1137,14 @@ function replacePlaceholders(template, data) {
1111
1137
  // so the mail and its attachment agree.
1112
1138
  invoiceDate: formatDateValue(inv.issueDate),
1113
1139
  dueDate: formatDateValue(inv.dueDate),
1114
- totalAmount: (inv.currencySymbol || '') + Number(inv.total || 0).toFixed(2),
1115
- subtotal: (inv.currencySymbol || '') + Number(inv.subtotal || 0).toFixed(2),
1116
- taxAmount: (inv.currencySymbol || '') + Number(inv.taxAmount || 0).toFixed(2),
1117
- shippingAmount: (inv.currencySymbol || '') + Number(inv.shippingAmount || 0).toFixed(2),
1118
- discountAmount: (inv.currencySymbol || '') + Number(inv.discountAmount || 0).toFixed(2),
1119
- giftCardAmount: (inv.currencySymbol || '') + Number(inv.giftCardAmount || 0).toFixed(2),
1140
+ totalAmount: formatCurrencyValue(inv.total, sym),
1141
+ subtotal: formatCurrencyValue(inv.subtotal, sym),
1142
+ taxAmount: formatCurrencyValue(inv.taxAmount, sym),
1143
+ shippingAmount: formatCurrencyValue(inv.shippingAmount, sym),
1144
+ discountAmount: formatCurrencyValue(inv.discountAmount, sym),
1145
+ giftCardAmount: formatCurrencyValue(inv.giftCardAmount, sym),
1120
1146
  // What is left after a gift card; the whole total when none was used.
1121
- amountDue: (inv.currencySymbol || '') + Number(inv.amountDue != null ? inv.amountDue : (inv.total || 0)).toFixed(2),
1147
+ amountDue: formatCurrencyValue(inv.amountDue != null ? inv.amountDue : inv.total, sym),
1122
1148
  companyName: comp.name || '',
1123
1149
  companyEmail: comp.email || '',
1124
1150
  invoiceUrl: inv.pdfUrl || '',
@@ -1136,6 +1162,9 @@ function buildDataContext(invoiceData) {
1136
1162
  return {
1137
1163
  invoice: {
1138
1164
  number: invoiceData.invoiceNumber || '',
1165
+ // Paid or not decides which lines the email keeps (see omittedLineTokens).
1166
+ status: invoiceData.status || '',
1167
+ paidAt: invoiceData.paidAt || null,
1139
1168
  issueDate: invoiceData.issueDate || '',
1140
1169
  dueDate: invoiceData.dueDate || '',
1141
1170
  subtotal: invoiceData.subtotal || 0,