@teleporthq/teleport-plugin-next-workflows 0.44.0 → 0.44.1
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.
- package/__tests__/_helpers/fake-store-database.ts +428 -0
- package/__tests__/_helpers/invoice-store.ts +382 -0
- package/__tests__/email-locale-senders.test.ts +3 -1
- package/__tests__/email-sender-name.test.ts +513 -0
- package/__tests__/graph-utils-topological-order.test.ts +155 -1
- package/__tests__/invoice-billing-details.test.ts +107 -1
- package/__tests__/invoice-data-access-replace.test.ts +236 -2
- package/__tests__/invoice-order-items-settle.test.ts +1 -1
- package/__tests__/invoice-payment-email.test.ts +191 -0
- package/__tests__/invoice-regenerate-email.test.ts +288 -0
- package/__tests__/invoice-regenerate-route.test.ts +9 -7
- package/__tests__/invoice-reservation.test.ts +1 -1
- package/__tests__/invoice-tender.test.ts +1 -1
- package/__tests__/segment-splitter-checkout-order.test.ts +306 -0
- package/__tests__/streaming-on-end-env-filter.test.ts +18 -18
- package/__tests__/streaming-on-end-order.test.ts +387 -0
- package/dist/cjs/account-delete-route-generator.d.ts +1 -0
- package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
- package/dist/cjs/account-delete-route-generator.js +2 -1
- package/dist/cjs/account-delete-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +1 -0
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +2 -1
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +9 -26
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/graph-utils.d.ts +31 -1
- package/dist/cjs/graph-utils.d.ts.map +1 -1
- package/dist/cjs/graph-utils.js +112 -10
- package/dist/cjs/graph-utils.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts +12 -3
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +152 -21
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/data-access-code.d.ts.map +1 -1
- package/dist/cjs/invoice/data-access-code.js +133 -21
- package/dist/cjs/invoice/data-access-code.js.map +1 -1
- package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
- package/dist/cjs/invoice/email-sender-code.js +15 -25
- package/dist/cjs/invoice/email-sender-code.js.map +1 -1
- package/dist/cjs/invoice/index.js +1 -1
- package/dist/cjs/invoice/index.js.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-assembly-code.js +10 -6
- package/dist/cjs/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +3 -0
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-mailgun.js +4 -2
- package/dist/cjs/nodes/email/email-mailgun.js.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-postmark.js +5 -2
- package/dist/cjs/nodes/email/email-postmark.js.map +1 -1
- package/dist/cjs/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-resend.js +5 -2
- package/dist/cjs/nodes/email/email-resend.js.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/cjs/nodes/email/email-sendgrid.js +8 -2
- package/dist/cjs/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-remove.d.ts.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-remove.js +2 -0
- package/dist/cjs/nodes/storage/storage-local-remove.js.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-set.d.ts.map +1 -1
- package/dist/cjs/nodes/storage/storage-local-set.js +4 -0
- package/dist/cjs/nodes/storage/storage-local-set.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +65 -27
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/transactional-email-code.d.ts.map +1 -1
- package/dist/cjs/transactional-email-code.js +32 -18
- package/dist/cjs/transactional-email-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +2 -0
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/account-delete-route-generator.d.ts +1 -0
- package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
- package/dist/esm/account-delete-route-generator.js +2 -1
- package/dist/esm/account-delete-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +1 -0
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +2 -1
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +9 -26
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/graph-utils.d.ts +31 -1
- package/dist/esm/graph-utils.d.ts.map +1 -1
- package/dist/esm/graph-utils.js +109 -9
- package/dist/esm/graph-utils.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts +12 -3
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +152 -21
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/data-access-code.d.ts.map +1 -1
- package/dist/esm/invoice/data-access-code.js +133 -21
- package/dist/esm/invoice/data-access-code.js.map +1 -1
- package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
- package/dist/esm/invoice/email-sender-code.js +15 -25
- package/dist/esm/invoice/email-sender-code.js.map +1 -1
- package/dist/esm/invoice/index.js +1 -1
- package/dist/esm/invoice/index.js.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-assembly-code.js +10 -6
- package/dist/esm/invoice/invoice-assembly-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +3 -0
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-mailgun.js +4 -2
- package/dist/esm/nodes/email/email-mailgun.js.map +1 -1
- package/dist/esm/nodes/email/email-postmark.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-postmark.js +5 -2
- package/dist/esm/nodes/email/email-postmark.js.map +1 -1
- package/dist/esm/nodes/email/email-resend.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-resend.js +5 -2
- package/dist/esm/nodes/email/email-resend.js.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.d.ts.map +1 -1
- package/dist/esm/nodes/email/email-sendgrid.js +8 -2
- package/dist/esm/nodes/email/email-sendgrid.js.map +1 -1
- package/dist/esm/nodes/storage/storage-local-remove.d.ts.map +1 -1
- package/dist/esm/nodes/storage/storage-local-remove.js +2 -0
- package/dist/esm/nodes/storage/storage-local-remove.js.map +1 -1
- package/dist/esm/nodes/storage/storage-local-set.d.ts.map +1 -1
- package/dist/esm/nodes/storage/storage-local-set.js +4 -0
- package/dist/esm/nodes/storage/storage-local-set.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +66 -28
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/transactional-email-code.d.ts.map +1 -1
- package/dist/esm/transactional-email-code.js +32 -18
- package/dist/esm/transactional-email-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +2 -0
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +3 -3
- package/src/account-delete-route-generator.ts +3 -1
- package/src/auth-generator.ts +3 -1
- package/src/executor-generator.ts +9 -26
- package/src/graph-utils.ts +143 -11
- package/src/invoice/api-routes-code.ts +166 -21
- package/src/invoice/data-access-code.ts +139 -21
- package/src/invoice/email-sender-code.ts +15 -25
- package/src/invoice/index.ts +1 -1
- package/src/invoice/invoice-assembly-code.ts +10 -6
- package/src/invoice/invoice-html-code.ts +3 -0
- package/src/nodes/email/email-mailgun.ts +11 -2
- package/src/nodes/email/email-postmark.ts +12 -2
- package/src/nodes/email/email-resend.ts +12 -2
- package/src/nodes/email/email-sendgrid.ts +15 -2
- package/src/nodes/storage/storage-local-remove.ts +4 -0
- package/src/nodes/storage/storage-local-set.ts +6 -0
- package/src/segment-splitter.ts +90 -36
- package/src/transactional-email-code.ts +32 -21
- package/src/workflow-project-plugin.ts +2 -0
|
@@ -91,7 +91,7 @@ async function runHandler(
|
|
|
91
91
|
invoice = invoiceData
|
|
92
92
|
return { invoice: invoiceData, created: true }
|
|
93
93
|
},
|
|
94
|
-
storeInvoicePdf: async () => ({}),
|
|
94
|
+
storeInvoicePdf: async () => ({ firstPdf: true }),
|
|
95
95
|
updateInvoice: async () => ({}),
|
|
96
96
|
},
|
|
97
97
|
pdfGenerator: {
|
|
@@ -122,6 +122,7 @@ async function runHandler(
|
|
|
122
122
|
|
|
123
123
|
interface PdfGeneratorModule {
|
|
124
124
|
buildInvoiceDataScope: (invoiceData: Row) => { invoiceData: Record<string, Row> }
|
|
125
|
+
buildInvoiceHtml: (invoiceData: Row) => string
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
function loadPdfGenerator(
|
|
@@ -214,6 +215,57 @@ describe('/api/invoices/generate — the buyer’s own invoice details', () => {
|
|
|
214
215
|
})
|
|
215
216
|
})
|
|
216
217
|
|
|
218
|
+
it('bills a store order shipped elsewhere at its billing address, under its company', async () => {
|
|
219
|
+
// The store checkout's "Billing Details", as its order records them; the
|
|
220
|
+
// goods go to someone else, in another town.
|
|
221
|
+
const storeOrder: Row = {
|
|
222
|
+
id: 'order-store',
|
|
223
|
+
order_number: 'ORD-7',
|
|
224
|
+
currency: 'USD',
|
|
225
|
+
total_amount: 40,
|
|
226
|
+
shipping_amount: 0,
|
|
227
|
+
billing_name: 'Ana Billing',
|
|
228
|
+
billing_email: 'ana@example.test',
|
|
229
|
+
billing_address: '1 Billing Street, Suite 4',
|
|
230
|
+
shipping_name: 'Ben Recipient',
|
|
231
|
+
shipping_address: '12 Delivery Lane',
|
|
232
|
+
shipping_city: 'Lyon',
|
|
233
|
+
shipping_state: 'Rhone',
|
|
234
|
+
shipping_zip: '69001',
|
|
235
|
+
shipping_country: 'FR',
|
|
236
|
+
metadata: JSON.stringify({
|
|
237
|
+
billingStreet: '1 Billing Street, Suite 4',
|
|
238
|
+
billingCompany: 'Acme Widgets Ltd',
|
|
239
|
+
billingCity: 'Halifax',
|
|
240
|
+
billingRegion: 'NS',
|
|
241
|
+
billingPostalCode: 'B3H 1A1',
|
|
242
|
+
billingCountry: 'CA',
|
|
243
|
+
}),
|
|
244
|
+
}
|
|
245
|
+
const items = [{ product_id: 'mug', product_name: 'Mug', quantity: 2, unit_price: 20 }]
|
|
246
|
+
const run = await runHandler(settingsWith({ defaultTaxRate: 0 }), storeOrder, items, {
|
|
247
|
+
orderId: 'order-store',
|
|
248
|
+
})
|
|
249
|
+
expect(run.status).toBe(200)
|
|
250
|
+
expect(run.invoice).toMatchObject({
|
|
251
|
+
customerName: 'Ana Billing',
|
|
252
|
+
customerCompany: 'Acme Widgets Ltd',
|
|
253
|
+
customerAddress: '1 Billing Street, Suite 4',
|
|
254
|
+
customerCity: 'Halifax',
|
|
255
|
+
customerState: 'NS',
|
|
256
|
+
customerZip: 'B3H 1A1',
|
|
257
|
+
customerCountry: 'CA',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const withoutCompany = await runHandler(
|
|
261
|
+
settingsWith({ defaultTaxRate: 0 }),
|
|
262
|
+
{ ...storeOrder, metadata: JSON.stringify({ billingStreet: '1 Billing Street' }) },
|
|
263
|
+
items,
|
|
264
|
+
{ orderId: 'order-store' }
|
|
265
|
+
)
|
|
266
|
+
expect(withoutCompany.invoice.customerCompany).toBe('')
|
|
267
|
+
})
|
|
268
|
+
|
|
217
269
|
it('reads no metadata that is not a JSON object, and lets the caller’s VAT win', async () => {
|
|
218
270
|
for (const metadata of ['not json', '[1,2]', null]) {
|
|
219
271
|
const run = await runHandler(
|
|
@@ -275,4 +327,58 @@ describe('invoice PDF renderer — the buyer, never the template’s sample', ()
|
|
|
275
327
|
})
|
|
276
328
|
expect(invoiceData.Payment.intentId).toBe('')
|
|
277
329
|
})
|
|
330
|
+
|
|
331
|
+
it('prints the company under the name only for a buyer who gave one', () => {
|
|
332
|
+
const withCompany = templated.buildInvoiceDataScope({
|
|
333
|
+
customerName: 'Ana Billing',
|
|
334
|
+
customerCompany: 'Acme Widgets Ltd',
|
|
335
|
+
items: [],
|
|
336
|
+
}).invoiceData.Customer
|
|
337
|
+
expect(withCompany).toMatchObject({ company: 'Acme Widgets Ltd', hasCompany: 'true' })
|
|
338
|
+
const withoutCompany = templated.buildInvoiceDataScope({ customerName: 'Ana', items: [] })
|
|
339
|
+
.invoiceData.Customer
|
|
340
|
+
expect(withoutCompany).toMatchObject({ company: '', hasCompany: 'false' })
|
|
341
|
+
|
|
342
|
+
// The template's company line, gated on its flag like the editor builds it.
|
|
343
|
+
const text = (children: unknown[]) => ({
|
|
344
|
+
type: 'element',
|
|
345
|
+
content: { elementType: 'text', semanticType: 'p', children },
|
|
346
|
+
})
|
|
347
|
+
const billTo = loadPdfGenerator(settings, {
|
|
348
|
+
name: 'invoice-template',
|
|
349
|
+
node: {
|
|
350
|
+
type: 'element',
|
|
351
|
+
content: {
|
|
352
|
+
elementType: 'container',
|
|
353
|
+
children: [
|
|
354
|
+
text([{ type: 'expr', content: 'invoiceData?.Customer?.name' }]),
|
|
355
|
+
{
|
|
356
|
+
type: 'conditional',
|
|
357
|
+
content: {
|
|
358
|
+
reference: { type: 'expr', content: 'invoiceData?.Customer?.hasCompany' },
|
|
359
|
+
condition: { conditions: [{ operation: '=', operand: 'true' }] },
|
|
360
|
+
node: text([{ type: 'expr', content: 'invoiceData?.Customer?.company' }]),
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
expect(
|
|
368
|
+
billTo.buildInvoiceHtml({
|
|
369
|
+
customerName: 'Ana',
|
|
370
|
+
customerCompany: 'Acme Widgets Ltd',
|
|
371
|
+
items: [],
|
|
372
|
+
})
|
|
373
|
+
).toContain('Acme Widgets Ltd')
|
|
374
|
+
expect(billTo.buildInvoiceHtml({ customerName: 'Ana', items: [] })).not.toContain('Acme')
|
|
375
|
+
|
|
376
|
+
// And the built-in layout used when a store has no template.
|
|
377
|
+
const fallback = loadPdfGenerator(settings, null).buildInvoiceHtml({
|
|
378
|
+
customerName: 'Ana Billing',
|
|
379
|
+
customerCompany: 'Acme Widgets Ltd',
|
|
380
|
+
items: [],
|
|
381
|
+
})
|
|
382
|
+
expect(fallback).toContain('<strong>Ana Billing</strong><br/>Acme Widgets Ltd<br/>')
|
|
383
|
+
})
|
|
278
384
|
})
|
|
@@ -21,6 +21,8 @@ interface DataAccess {
|
|
|
21
21
|
invoiceNumber: string,
|
|
22
22
|
pdfUrl: string
|
|
23
23
|
) => Promise<boolean>
|
|
24
|
+
recordOrderEvent: (event: Row) => Promise<void>
|
|
25
|
+
storeInvoicePdf: (id: string, pdf: Buffer, pdfUrl: string) => Promise<Row>
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
interface Statement {
|
|
@@ -153,6 +155,96 @@ describe('invoice data-access — replaceInvoice', () => {
|
|
|
153
155
|
)
|
|
154
156
|
expect(released()).toBe(2)
|
|
155
157
|
})
|
|
158
|
+
|
|
159
|
+
it('tells the regeneration that gave the invoice its first PDF, by writing only a row without one', async () => {
|
|
160
|
+
const { dataAccess, statements } = loadPostgres()
|
|
161
|
+
expect(await dataAccess.replaceInvoice('inv-1', INVOICE_DATA, ITEMS)).toEqual({
|
|
162
|
+
firstPdf: true,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const updates = statements.filter((statement) => statement.sql.startsWith('UPDATE'))
|
|
166
|
+
expect(updates).toHaveLength(1)
|
|
167
|
+
expect(updates[0].sql).toMatch(/ WHERE id = \$\d+ AND pdf_size_bytes IS NULL$/)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('rewrites an invoice that already had a PDF all the same, and says it was not the first', async () => {
|
|
171
|
+
const { dataAccess, statements } = loadPostgres((sql) =>
|
|
172
|
+
sql.endsWith('AND pdf_size_bytes IS NULL') ? { rowCount: 0 } : { rowCount: 1 }
|
|
173
|
+
)
|
|
174
|
+
expect(await dataAccess.replaceInvoice('inv-1', INVOICE_DATA, ITEMS)).toEqual({
|
|
175
|
+
firstPdf: false,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const updates = statements.filter((statement) => statement.sql.startsWith('UPDATE'))
|
|
179
|
+
expect(updates).toHaveLength(2)
|
|
180
|
+
expect(updates[1].sql).toMatch(/ WHERE id = \$\d+$/)
|
|
181
|
+
expect(updates[1].values).toEqual(updates[0].values)
|
|
182
|
+
expect(statements.map((statement) => statement.sql)).toContain('COMMIT')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
/** The emitted MySQL data access over a mysql2 stub; `affected` answers each UPDATE. */
|
|
187
|
+
function loadMysql(affected: (sql: string) => number) {
|
|
188
|
+
const statements: string[] = []
|
|
189
|
+
const connection = {
|
|
190
|
+
beginTransaction: async () => statements.push('BEGIN'),
|
|
191
|
+
commit: async () => statements.push('COMMIT'),
|
|
192
|
+
rollback: async () => statements.push('ROLLBACK'),
|
|
193
|
+
release: (): void => undefined,
|
|
194
|
+
execute: async (sql: string) => {
|
|
195
|
+
statements.push(sql)
|
|
196
|
+
return [{ affectedRows: sql.startsWith('UPDATE') ? affected(sql) : 1, insertId: 1 }]
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
const mysqlStub = {
|
|
200
|
+
createPool: () => ({ getConnection: async () => connection, execute: connection.execute }),
|
|
201
|
+
}
|
|
202
|
+
const code = `${generateDataAccessCode(SETTINGS, 'mysql', null)}\n${getRecordMappingCode(
|
|
203
|
+
'mysql'
|
|
204
|
+
)}`
|
|
205
|
+
const moduleObject = { exports: {} as unknown }
|
|
206
|
+
const dataAccess = new Function(
|
|
207
|
+
'require',
|
|
208
|
+
'module',
|
|
209
|
+
'exports',
|
|
210
|
+
`${code}; return module.exports;`
|
|
211
|
+
)(
|
|
212
|
+
(id: string) => (id === 'mysql2/promise' ? mysqlStub : require(id)),
|
|
213
|
+
moduleObject,
|
|
214
|
+
moduleObject.exports
|
|
215
|
+
) as DataAccess
|
|
216
|
+
return { dataAccess, statements }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
describe('invoice data-access — replaceInvoice on MySQL', () => {
|
|
220
|
+
it('tells the regeneration that gave the invoice its first PDF', async () => {
|
|
221
|
+
const { dataAccess, statements } = loadMysql(() => 1)
|
|
222
|
+
expect(await dataAccess.replaceInvoice('inv-1', INVOICE_DATA, ITEMS)).toEqual({
|
|
223
|
+
firstPdf: true,
|
|
224
|
+
})
|
|
225
|
+
const updates = statements.filter((sql) => sql.startsWith('UPDATE'))
|
|
226
|
+
expect(updates).toHaveLength(1)
|
|
227
|
+
expect(updates[0]).toMatch(/ WHERE id = \? AND pdf_size_bytes IS NULL$/)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('rewrites an invoice that already had a PDF, and says it was not the first', async () => {
|
|
231
|
+
const { dataAccess, statements } = loadMysql((sql) =>
|
|
232
|
+
sql.endsWith('AND pdf_size_bytes IS NULL') ? 0 : 1
|
|
233
|
+
)
|
|
234
|
+
expect(await dataAccess.replaceInvoice('inv-1', INVOICE_DATA, ITEMS)).toEqual({
|
|
235
|
+
firstPdf: false,
|
|
236
|
+
})
|
|
237
|
+
expect(statements.filter((sql) => sql.startsWith('UPDATE'))).toHaveLength(2)
|
|
238
|
+
expect(statements).toContain('COMMIT')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('reports an invoice that disappeared', async () => {
|
|
242
|
+
const { dataAccess, statements } = loadMysql(() => 0)
|
|
243
|
+
await expect(dataAccess.replaceInvoice('inv-1', INVOICE_DATA, ITEMS)).rejects.toThrow(
|
|
244
|
+
'Invoice inv-1 no longer exists'
|
|
245
|
+
)
|
|
246
|
+
expect(statements).toContain('ROLLBACK')
|
|
247
|
+
})
|
|
156
248
|
})
|
|
157
249
|
|
|
158
250
|
type SupabaseResult = { data: unknown; error: { message: string } | null }
|
|
@@ -162,9 +254,14 @@ type SupabaseResult = { data: unknown; error: { message: string } | null }
|
|
|
162
254
|
* `failOn(op, table, nth)` fails the nth (1-based) call of that operation on
|
|
163
255
|
* that table.
|
|
164
256
|
*/
|
|
165
|
-
function loadSupabase(
|
|
257
|
+
function loadSupabase(
|
|
258
|
+
failOn: (op: string, table: string, nth: number) => boolean = () => false,
|
|
259
|
+
storedInvoice: Row = {}
|
|
260
|
+
) {
|
|
166
261
|
const store: Record<string, Row[]> = {
|
|
167
|
-
teleport_invoices: [
|
|
262
|
+
teleport_invoices: [
|
|
263
|
+
{ id: 'inv-1', invoice_number: 'INV-0004', total: 90, notes: 'old', ...storedInvoice },
|
|
264
|
+
],
|
|
168
265
|
teleport_invoice_items: [{ id: 'old-1', invoice_id: 'inv-1', name: 'Old lamp' }],
|
|
169
266
|
}
|
|
170
267
|
const counts: Record<string, number> = {}
|
|
@@ -225,6 +322,11 @@ function loadSupabase(failOn: (op: string, table: string, nth: number) => boolea
|
|
|
225
322
|
filters.push((row) => values.includes(row[column]))
|
|
226
323
|
return builder
|
|
227
324
|
},
|
|
325
|
+
// `is(column, null)`: SQL NULL, which an absent column also reads as.
|
|
326
|
+
is: (column: string, value: unknown) => {
|
|
327
|
+
filters.push((row) => (value === null ? row[column] == null : row[column] === value))
|
|
328
|
+
return builder
|
|
329
|
+
},
|
|
228
330
|
maybeSingle: () => {
|
|
229
331
|
single = true
|
|
230
332
|
return builder
|
|
@@ -294,6 +396,35 @@ describe('invoice data-access — replaceInvoice on Supabase (no transactions)',
|
|
|
294
396
|
)
|
|
295
397
|
expect(lineNames()).toEqual(['Old lamp'])
|
|
296
398
|
})
|
|
399
|
+
|
|
400
|
+
it('tells the regeneration that gave the invoice its first PDF', async () => {
|
|
401
|
+
const { dataAccess, invoice } = loadSupabase()
|
|
402
|
+
expect(
|
|
403
|
+
await dataAccess.replaceInvoice('inv-1', { ...INVOICE_DATA, pdfSizeBytes: 2048 }, ITEMS)
|
|
404
|
+
).toEqual({ firstPdf: true })
|
|
405
|
+
expect(invoice()).toMatchObject({ total: 105, pdf_size_bytes: 2048 })
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('rewrites an invoice that already had a PDF, and says it was not the first', async () => {
|
|
409
|
+
const { dataAccess, invoice } = loadSupabase(() => false, { pdf_size_bytes: 1024 })
|
|
410
|
+
expect(
|
|
411
|
+
await dataAccess.replaceInvoice('inv-1', { ...INVOICE_DATA, pdfSizeBytes: 2048 }, ITEMS)
|
|
412
|
+
).toEqual({ firstPdf: false })
|
|
413
|
+
expect(invoice()).toMatchObject({ total: 105, pdf_size_bytes: 2048 })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('puts the invoice back without its PDF when a later step fails, so the next regeneration is the first', async () => {
|
|
417
|
+
const { dataAccess, invoice } = loadSupabase(
|
|
418
|
+
(op, table, nth) => op === 'delete' && table === 'teleport_invoice_items' && nth === 1
|
|
419
|
+
)
|
|
420
|
+
await expect(
|
|
421
|
+
dataAccess.replaceInvoice('inv-1', { ...INVOICE_DATA, pdfSizeBytes: 2048 }, ITEMS)
|
|
422
|
+
).rejects.toThrow('delete teleport_invoice_items failed')
|
|
423
|
+
expect(invoice().pdf_size_bytes == null).toBe(true)
|
|
424
|
+
expect(
|
|
425
|
+
await dataAccess.replaceInvoice('inv-1', { ...INVOICE_DATA, pdfSizeBytes: 2048 }, ITEMS)
|
|
426
|
+
).toEqual({ firstPdf: true })
|
|
427
|
+
})
|
|
297
428
|
})
|
|
298
429
|
|
|
299
430
|
describe('invoice data-access — linkOrderToInvoice', () => {
|
|
@@ -327,6 +458,46 @@ describe('invoice data-access — linkOrderToInvoice', () => {
|
|
|
327
458
|
})
|
|
328
459
|
})
|
|
329
460
|
|
|
461
|
+
describe('invoice data-access — recordOrderEvent', () => {
|
|
462
|
+
it("writes the order's history line once per key", async () => {
|
|
463
|
+
const { dataAccess, statements } = loadPostgres()
|
|
464
|
+
await dataAccess.recordOrderEvent({
|
|
465
|
+
type: 'order-email-sent',
|
|
466
|
+
dedupeKey: 'order-email-sent:inv-1:invoice-email',
|
|
467
|
+
orderId: 'order-1',
|
|
468
|
+
payload: { purpose: 'invoice-email', to: 'ana@buyer.test' },
|
|
469
|
+
actor: 'Store admin',
|
|
470
|
+
actorType: 'merchant',
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const [statement] = statements
|
|
474
|
+
expect(statement.sql).toBe(
|
|
475
|
+
'INSERT INTO teleport_events (id, event_type, dedupe_key, order_id, payload, actor, actor_type, status) ' +
|
|
476
|
+
'VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (dedupe_key) DO NOTHING'
|
|
477
|
+
)
|
|
478
|
+
expect(statement.values.slice(1)).toEqual([
|
|
479
|
+
'order-email-sent',
|
|
480
|
+
'order-email-sent:inv-1:invoice-email',
|
|
481
|
+
'order-1',
|
|
482
|
+
'{"purpose":"invoice-email","to":"ana@buyer.test"}',
|
|
483
|
+
'Store admin',
|
|
484
|
+
'merchant',
|
|
485
|
+
'recorded',
|
|
486
|
+
])
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('keeps the key within the column', async () => {
|
|
490
|
+
const { dataAccess, statements } = loadPostgres()
|
|
491
|
+
await dataAccess.recordOrderEvent({
|
|
492
|
+
type: 'order-email-sent',
|
|
493
|
+
dedupeKey: 'k'.repeat(300),
|
|
494
|
+
actorType: 'merchant',
|
|
495
|
+
})
|
|
496
|
+
expect(String(statements[0].values[2])).toHaveLength(255)
|
|
497
|
+
expect(statements[0].values[3]).toBeNull()
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
|
|
330
501
|
describe('invoice data-access — every dialect exports the regeneration helpers', () => {
|
|
331
502
|
it.each(['postgresql', 'mysql', 'supabase'])('%s', (dialect) => {
|
|
332
503
|
const code = generateDataAccessCode(SETTINGS, dialect as never, null)
|
|
@@ -334,7 +505,70 @@ describe('invoice data-access — every dialect exports the regeneration helpers
|
|
|
334
505
|
expect(code).toContain(
|
|
335
506
|
'async function linkOrderToInvoice(orderId, invoiceId, invoiceNumber, pdfUrl)'
|
|
336
507
|
)
|
|
508
|
+
expect(code).toContain('async function recordOrderEvent(event)')
|
|
337
509
|
expect(code).toContain('replaceInvoice: replaceInvoice,')
|
|
338
510
|
expect(code).toContain('linkOrderToInvoice: linkOrderToInvoice,')
|
|
511
|
+
expect(code).toContain('recordOrderEvent: recordOrderEvent,')
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* `storeInvoicePdf` — the payment's PDF, written only on an invoice that has
|
|
517
|
+
* none, so the invoice's first PDF (and with it its email) goes to exactly one
|
|
518
|
+
* of the payment and a regeneration, as `replaceInvoice` claims it.
|
|
519
|
+
*/
|
|
520
|
+
describe('invoice data-access — storeInvoicePdf writes only the first PDF', () => {
|
|
521
|
+
const PDF = Buffer.from('%PDF-1.4 INV-0004')
|
|
522
|
+
|
|
523
|
+
it('Postgres: one statement, on a row without a PDF', async () => {
|
|
524
|
+
const { dataAccess, statements } = loadPostgres()
|
|
525
|
+
expect(await dataAccess.storeInvoicePdf('inv-1', PDF, '/api/invoices/inv-1/pdf')).toEqual({
|
|
526
|
+
firstPdf: true,
|
|
527
|
+
})
|
|
528
|
+
expect(statements).toHaveLength(1)
|
|
529
|
+
expect(statements[0].sql).toBe(
|
|
530
|
+
'UPDATE teleport_invoices SET pdf_data = $1, pdf_size_bytes = $2, pdf_content_type = $3, ' +
|
|
531
|
+
'pdf_url = $4, updated_at = $5 WHERE id = $6 AND pdf_size_bytes IS NULL'
|
|
532
|
+
)
|
|
533
|
+
expect(statements[0].values).toEqual([
|
|
534
|
+
PDF,
|
|
535
|
+
PDF.length,
|
|
536
|
+
'application/pdf',
|
|
537
|
+
'/api/invoices/inv-1/pdf',
|
|
538
|
+
expect.any(String),
|
|
539
|
+
'inv-1',
|
|
540
|
+
])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('Postgres: an invoice that already has a PDF keeps it, and is not the first', async () => {
|
|
544
|
+
const { dataAccess } = loadPostgres(() => ({ rowCount: 0 }))
|
|
545
|
+
expect(await dataAccess.storeInvoicePdf('inv-1', PDF, '/api/invoices/inv-1/pdf')).toEqual({
|
|
546
|
+
firstPdf: false,
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('MySQL: the same condition, and the rows it changed', async () => {
|
|
551
|
+
const first = loadMysql(() => 1)
|
|
552
|
+
expect(await first.dataAccess.storeInvoicePdf('inv-1', PDF, '/x')).toEqual({ firstPdf: true })
|
|
553
|
+
expect(first.statements).toEqual([
|
|
554
|
+
'UPDATE teleport_invoices SET pdf_data = ?, pdf_size_bytes = ?, pdf_content_type = ?, ' +
|
|
555
|
+
'pdf_url = ?, updated_at = ? WHERE id = ? AND pdf_size_bytes IS NULL',
|
|
556
|
+
])
|
|
557
|
+
const later = loadMysql(() => 0)
|
|
558
|
+
expect(await later.dataAccess.storeInvoicePdf('inv-1', PDF, '/x')).toEqual({ firstPdf: false })
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('Supabase: one filtered update', async () => {
|
|
562
|
+
const first = loadSupabase()
|
|
563
|
+
expect(await first.dataAccess.storeInvoicePdf('inv-1', PDF, '/x')).toEqual({ firstPdf: true })
|
|
564
|
+
expect(first.invoice()).toMatchObject({
|
|
565
|
+
pdf_data: PDF.toString('base64'),
|
|
566
|
+
pdf_size_bytes: PDF.length,
|
|
567
|
+
pdf_url: '/x',
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const later = loadSupabase(() => false, { pdf_size_bytes: 1024, pdf_url: '/regenerated' })
|
|
571
|
+
expect(await later.dataAccess.storeInvoicePdf('inv-1', PDF, '/x')).toEqual({ firstPdf: false })
|
|
572
|
+
expect(later.invoice()).toMatchObject({ pdf_size_bytes: 1024, pdf_url: '/regenerated' })
|
|
339
573
|
})
|
|
340
574
|
})
|
|
@@ -120,7 +120,7 @@ async function runHandler(options: {
|
|
|
120
120
|
insertedItems.push(...items)
|
|
121
121
|
return { invoice: invoiceData, created: true }
|
|
122
122
|
},
|
|
123
|
-
storeInvoicePdf: async () => ({}),
|
|
123
|
+
storeInvoicePdf: async () => ({ firstPdf: true }),
|
|
124
124
|
updateInvoice: async () => ({}),
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUYER,
|
|
3
|
+
ORDER_ID,
|
|
4
|
+
Row,
|
|
5
|
+
Store,
|
|
6
|
+
historyLines,
|
|
7
|
+
invoiceOf,
|
|
8
|
+
ledgerRows,
|
|
9
|
+
openStore,
|
|
10
|
+
payWhilePdfServiceIsDown,
|
|
11
|
+
runsAsTheStore,
|
|
12
|
+
seedPaidOrder,
|
|
13
|
+
} from './_helpers/invoice-store'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The invoice email a payment sends is on its order's history.
|
|
17
|
+
*
|
|
18
|
+
* With Invoice Email Delivery on, the payment's `/api/invoices/generate`
|
|
19
|
+
* emails the buyer the invoice it just issued. The admin panel's Regenerate
|
|
20
|
+
* writes an "email sent" line on the order's history when it emails one; the
|
|
21
|
+
* payment wrote none, so the order never showed that its buyer got the
|
|
22
|
+
* invoice.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is the code the store runs, as emitted (see
|
|
25
|
+
* `_helpers/invoice-store.ts`); only its database, the PDF service and the
|
|
26
|
+
* email provider are faked.
|
|
27
|
+
*/
|
|
28
|
+
describe("the payment's invoice email is on its order's history", () => {
|
|
29
|
+
runsAsTheStore()
|
|
30
|
+
|
|
31
|
+
it('the email the payment sends writes one "email sent" line, by the store', async () => {
|
|
32
|
+
const store = openStore()
|
|
33
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
34
|
+
|
|
35
|
+
const paid = await store.pay(ORDER_ID)
|
|
36
|
+
|
|
37
|
+
expect(paid.status).toBe(200)
|
|
38
|
+
const invoice = invoiceOf(store, ORDER_ID)
|
|
39
|
+
expect(store.emails.map((email) => email.To)).toEqual([BUYER])
|
|
40
|
+
expect(ledgerRows(store)).toEqual([
|
|
41
|
+
expect.objectContaining({ status: 'sent', source_ref: 'api/invoices/generate' }),
|
|
42
|
+
])
|
|
43
|
+
// The line the regeneration writes, keyed on the same invoice: an
|
|
44
|
+
// invoice's email is on its order's history once, whoever sent it.
|
|
45
|
+
expect(historyLines(store)).toEqual([
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
event_type: 'order-email-sent',
|
|
48
|
+
dedupe_key: 'order-email-sent:' + String(invoice.id) + ':invoice-email',
|
|
49
|
+
order_id: ORDER_ID,
|
|
50
|
+
payload: JSON.stringify({ purpose: 'invoice-email', to: BUYER }),
|
|
51
|
+
actor: '',
|
|
52
|
+
actor_type: 'system',
|
|
53
|
+
status: 'recorded',
|
|
54
|
+
}),
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('a payment reported again sends nothing and writes no second line', async () => {
|
|
59
|
+
const store = openStore()
|
|
60
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
61
|
+
expect((await store.pay(ORDER_ID)).status).toBe(200)
|
|
62
|
+
|
|
63
|
+
const again = await store.pay(ORDER_ID)
|
|
64
|
+
|
|
65
|
+
expect(again.body).toMatchObject({ success: true, alreadyIssued: true })
|
|
66
|
+
expect(store.emails).toHaveLength(1)
|
|
67
|
+
expect(historyLines(store)).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('an invoice an admin issues by hand is on the history under their name', async () => {
|
|
71
|
+
const store = openStore()
|
|
72
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
73
|
+
|
|
74
|
+
expect((await store.issueAsAdmin(ORDER_ID)).status).toBe(200)
|
|
75
|
+
|
|
76
|
+
expect(store.emails).toHaveLength(1)
|
|
77
|
+
expect(historyLines(store)).toEqual([
|
|
78
|
+
expect.objectContaining({ actor: 'Store admin', actor_type: 'merchant' }),
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a refused email writes no line, and the log says it failed', async () => {
|
|
83
|
+
const store = openStore()
|
|
84
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
85
|
+
store.provider.refuses = true
|
|
86
|
+
|
|
87
|
+
const paid = await store.pay(ORDER_ID)
|
|
88
|
+
|
|
89
|
+
expect(paid.status).toBe(200)
|
|
90
|
+
expect(store.emails).toHaveLength(1)
|
|
91
|
+
expect(ledgerRows(store)).toEqual([expect.objectContaining({ status: 'failed' })])
|
|
92
|
+
expect(historyLines(store)).toEqual([])
|
|
93
|
+
expect(store.errors).toContainEqual(expect.stringContaining('Email delivery FAILED'))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a history that cannot be written costs nothing else: the email goes and the payment is answered', async () => {
|
|
97
|
+
const store = openStore()
|
|
98
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
99
|
+
store.db.failNext(/^INSERT INTO teleport_events /, 'relation "teleport_events" does not exist')
|
|
100
|
+
|
|
101
|
+
const paid = await store.pay(ORDER_ID)
|
|
102
|
+
|
|
103
|
+
expect(paid.status).toBe(200)
|
|
104
|
+
expect(paid.body).toMatchObject({ success: true, invoiceNumber: 'INV-0001' })
|
|
105
|
+
expect(store.emails).toHaveLength(1)
|
|
106
|
+
expect(historyLines(store)).toEqual([])
|
|
107
|
+
expect(store.errors).toContainEqual(
|
|
108
|
+
expect.stringContaining('INV-0001 was emailed, but the order history could not record it')
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('Invoice Email Delivery off: no email and no line', async () => {
|
|
113
|
+
const store = openStore({ emailDelivery: false })
|
|
114
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
115
|
+
|
|
116
|
+
expect((await store.pay(ORDER_ID)).status).toBe(200)
|
|
117
|
+
|
|
118
|
+
expect(store.emails).toEqual([])
|
|
119
|
+
expect(historyLines(store)).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A retried payment and the merchant's Regenerate finishing the same invoice.
|
|
125
|
+
*
|
|
126
|
+
* The PDF service was down at payment, so the invoice is reserved without a
|
|
127
|
+
* PDF. A quarter of an hour later the provider retries the payment, which
|
|
128
|
+
* claims the stale reservation and renders it — and the merchant clicks
|
|
129
|
+
* Regenerate meanwhile. Each route emails the buyer when it gives the invoice
|
|
130
|
+
* its first PDF, so the database must give that to exactly one of them.
|
|
131
|
+
*/
|
|
132
|
+
describe('a retried payment and a regeneration finishing the same invoice', () => {
|
|
133
|
+
runsAsTheStore()
|
|
134
|
+
|
|
135
|
+
const PAYMENT_PDF = /^UPDATE teleport_invoices SET "?pdf_data"? = \$1/
|
|
136
|
+
const REGENERATION_PDF = /^UPDATE teleport_invoices SET "invoice_number" = \$1/
|
|
137
|
+
|
|
138
|
+
/** The order paid while the PDF service was down, its reservation older than any request lives. */
|
|
139
|
+
async function staleInvoice(store: Store): Promise<Row> {
|
|
140
|
+
seedPaidOrder(store.db, ORDER_ID)
|
|
141
|
+
const invoice = await payWhilePdfServiceIsDown(store, ORDER_ID)
|
|
142
|
+
invoice.updated_at = new Date(Date.now() - 20 * 60 * 1000).toISOString()
|
|
143
|
+
return invoice
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("the regeneration's PDF lands first: it emails the buyer, and the payment answers with its invoice", async () => {
|
|
147
|
+
const store = openStore()
|
|
148
|
+
const invoice = await staleInvoice(store)
|
|
149
|
+
const paymentsPdf = store.db.pauseNext(PAYMENT_PDF)
|
|
150
|
+
|
|
151
|
+
const payment = store.pay(ORDER_ID)
|
|
152
|
+
await paymentsPdf.reached
|
|
153
|
+
const regenerated = await store.regenerate(String(invoice.id))
|
|
154
|
+
paymentsPdf.release()
|
|
155
|
+
const paid = await payment
|
|
156
|
+
|
|
157
|
+
expect(store.emails.map((email) => email.To)).toEqual([BUYER])
|
|
158
|
+
expect(ledgerRows(store).map((row) => row.source_ref)).toEqual(['api/invoices/[id]/regenerate'])
|
|
159
|
+
expect(historyLines(store)).toEqual([expect.objectContaining({ actor_type: 'merchant' })])
|
|
160
|
+
expect(regenerated.status).toBe(200)
|
|
161
|
+
expect(paid.status).toBe(200)
|
|
162
|
+
expect(paid.body).toMatchObject({
|
|
163
|
+
success: true,
|
|
164
|
+
alreadyIssued: true,
|
|
165
|
+
invoiceId: invoice.id,
|
|
166
|
+
invoiceNumber: 'INV-0001',
|
|
167
|
+
pdfUrl: '/api/invoices/' + String(invoice.id) + '/pdf',
|
|
168
|
+
})
|
|
169
|
+
expect(store.db.row('teleport_orders', ORDER_ID)).toMatchObject({ invoice_id: invoice.id })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("the payment's PDF lands first: it emails the buyer, and the regeneration does not", async () => {
|
|
173
|
+
const store = openStore()
|
|
174
|
+
const invoice = await staleInvoice(store)
|
|
175
|
+
const regenerationsPdf = store.db.pauseNext(REGENERATION_PDF)
|
|
176
|
+
|
|
177
|
+
const regeneration = store.regenerate(String(invoice.id))
|
|
178
|
+
await regenerationsPdf.reached
|
|
179
|
+
const paid = await store.pay(ORDER_ID)
|
|
180
|
+
regenerationsPdf.release()
|
|
181
|
+
const regenerated = await regeneration
|
|
182
|
+
|
|
183
|
+
expect(store.emails.map((email) => email.To)).toEqual([BUYER])
|
|
184
|
+
expect(ledgerRows(store).map((row) => row.source_ref)).toEqual(['api/invoices/generate'])
|
|
185
|
+
expect(historyLines(store)).toEqual([expect.objectContaining({ actor_type: 'system' })])
|
|
186
|
+
expect(paid.status).toBe(200)
|
|
187
|
+
expect(paid.body).toMatchObject({ success: true, invoiceId: invoice.id })
|
|
188
|
+
expect(paid.body).not.toHaveProperty('alreadyIssued')
|
|
189
|
+
expect(regenerated.status).toBe(200)
|
|
190
|
+
})
|
|
191
|
+
})
|