@teleporthq/teleport-plugin-next-workflows 0.43.57 → 0.43.58
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__/invoice-generate-hydration.test.ts +7 -2
- package/__tests__/invoice-order-items-settle.test.ts +308 -0
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +132 -3
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +132 -3
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/invoice/api-routes-code.ts +132 -3
|
@@ -90,8 +90,13 @@ describe('/api/invoices/generate — orderId hydration', () => {
|
|
|
90
90
|
it('prefers body.items over hydrated items when both exist', () => {
|
|
91
91
|
// Admin "regenerate invoice with custom items" use cases need
|
|
92
92
|
// body.items to win. The condition checks length > 0 so an empty
|
|
93
|
-
// explicit array still falls back to DB-hydrated items.
|
|
94
|
-
|
|
93
|
+
// explicit array still falls back to DB-hydrated items. The predicate is
|
|
94
|
+
// named because the settle-wait below keys off it too — a caller that
|
|
95
|
+
// brought its own lines never waits for the DB rows to land.
|
|
96
|
+
expect(route).toContain(
|
|
97
|
+
'var callerSuppliedItems = Array.isArray(body.items) && body.items.length > 0;'
|
|
98
|
+
)
|
|
99
|
+
expect(route).toContain('var items = callerSuppliedItems')
|
|
95
100
|
})
|
|
96
101
|
|
|
97
102
|
it('falls back to order.billing_* / shipping_* fields for customer data', () => {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { generateInvoiceGenerateRouteCode } from '../src/invoice/api-routes-code'
|
|
2
|
+
import type { UIDLInvoiceSettings } from '@teleporthq/teleport-types'
|
|
3
|
+
|
|
4
|
+
// `/api/invoices/generate` used to invoice a HALF-WRITTEN order.
|
|
5
|
+
//
|
|
6
|
+
// The runtime `data-create-item` handler fire-and-forgets
|
|
7
|
+
// `/api/ecommerce/order-notification` the moment the `teleport_orders` row
|
|
8
|
+
// lands, and that route generates the invoice for every non-webhook payment
|
|
9
|
+
// method. But the checkout workflow writes `teleport_order_items` AFTER the
|
|
10
|
+
// order row, one HTTP round-trip per cart line — so the invoice endpoint was
|
|
11
|
+
// reading the line items while they were still being inserted.
|
|
12
|
+
//
|
|
13
|
+
// Observed in a real store: a 3-line order (4x Linen Table Runner, 3x Ceramic
|
|
14
|
+
// Dinner Plate Set, 2x Wireless Bluetooth Speaker, charged 474.45) produced an
|
|
15
|
+
// invoice with ONE line item and a total of 249.00 — the two rows that had not
|
|
16
|
+
// been inserted yet were simply absent, and nothing anywhere said so.
|
|
17
|
+
//
|
|
18
|
+
// These tests run the EMITTED handler against a data-access stub whose rows
|
|
19
|
+
// arrive progressively, which is what the race actually looks like.
|
|
20
|
+
|
|
21
|
+
const FAKE_SETTINGS = {
|
|
22
|
+
invoicePrefix: 'INV-',
|
|
23
|
+
defaultTaxRate: 10,
|
|
24
|
+
showDiscount: false,
|
|
25
|
+
taxIncludedInPrice: false,
|
|
26
|
+
companyDetails: {},
|
|
27
|
+
template: { document: null as unknown },
|
|
28
|
+
tables: { invoicesTable: 'teleport_invoices', invoiceItemsTable: 'teleport_invoice_items' },
|
|
29
|
+
emailDelivery: { enabled: false },
|
|
30
|
+
} as unknown as UIDLInvoiceSettings
|
|
31
|
+
|
|
32
|
+
interface OrderItemRow {
|
|
33
|
+
product_id: string
|
|
34
|
+
product_name: string
|
|
35
|
+
quantity: number
|
|
36
|
+
unit_price: number
|
|
37
|
+
total_price: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ORDER_ROW_BASE = {
|
|
41
|
+
id: 'order-1',
|
|
42
|
+
currency: 'USD',
|
|
43
|
+
total_amount: 474.45,
|
|
44
|
+
shipping_amount: 150,
|
|
45
|
+
billing_name: 'Jane Buyer',
|
|
46
|
+
billing_email: 'jane@example.com',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ITEM_ROWS: OrderItemRow[] = [
|
|
50
|
+
{
|
|
51
|
+
product_id: 'p1',
|
|
52
|
+
product_name: 'Linen Table Runner',
|
|
53
|
+
quantity: 4,
|
|
54
|
+
unit_price: 22.5,
|
|
55
|
+
total_price: 90,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
product_id: 'p2',
|
|
59
|
+
product_name: 'Ceramic Dinner Plate Set',
|
|
60
|
+
quantity: 3,
|
|
61
|
+
unit_price: 34.99,
|
|
62
|
+
total_price: 104.97,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
product_id: 'p3',
|
|
66
|
+
product_name: 'Wireless Bluetooth Speaker',
|
|
67
|
+
quantity: 2,
|
|
68
|
+
unit_price: 49.99,
|
|
69
|
+
total_price: 99.98,
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
interface HandlerRun {
|
|
74
|
+
status: number
|
|
75
|
+
payload: Record<string, unknown>
|
|
76
|
+
insertedItems: unknown[]
|
|
77
|
+
reads: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Loads the emitted `pages/api/invoices/generate.js` as a real module and
|
|
82
|
+
* invokes its handler.
|
|
83
|
+
*
|
|
84
|
+
* `orderNumberAfterRead` controls when checkout's order-number backfill
|
|
85
|
+
* becomes visible — that write happens after the item loop in both the COD
|
|
86
|
+
* and the online-payment branch, so it is one of the endpoint's "the order is
|
|
87
|
+
* whole now" signals. `Infinity` models a flow that never sets one.
|
|
88
|
+
*/
|
|
89
|
+
async function runHandler(options: {
|
|
90
|
+
body: Record<string, unknown>
|
|
91
|
+
/** Number of item rows visible on the Nth read (1-indexed reads). */
|
|
92
|
+
visibleItemsPerRead: (read: number) => number
|
|
93
|
+
orderNumberAfterRead?: number
|
|
94
|
+
}): Promise<HandlerRun> {
|
|
95
|
+
const code = generateInvoiceGenerateRouteCode(FAKE_SETTINGS)
|
|
96
|
+
|
|
97
|
+
let reads = 0
|
|
98
|
+
const insertedItems: unknown[] = []
|
|
99
|
+
const orderNumberAfterRead = options.orderNumberAfterRead ?? 1
|
|
100
|
+
|
|
101
|
+
const dataAccessStub = {
|
|
102
|
+
getOrderWithItems: async (orderId: string) => {
|
|
103
|
+
reads += 1
|
|
104
|
+
if (orderId !== ORDER_ROW_BASE.id) {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
order: {
|
|
109
|
+
...ORDER_ROW_BASE,
|
|
110
|
+
order_number: reads >= orderNumberAfterRead ? 'ORD-1' : null,
|
|
111
|
+
},
|
|
112
|
+
items: ITEM_ROWS.slice(0, options.visibleItemsPerRead(reads)),
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
getNextInvoiceNumber: async () => 1,
|
|
116
|
+
insertInvoice: async (invoiceData: Record<string, unknown>) => invoiceData,
|
|
117
|
+
insertInvoiceItems: async (_invoiceId: string, items: unknown[]) => {
|
|
118
|
+
insertedItems.push(...items)
|
|
119
|
+
return items
|
|
120
|
+
},
|
|
121
|
+
updateInvoice: async () => ({}),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pdfGeneratorStub = {
|
|
125
|
+
generateInvoicePdf: async () => Buffer.from('%PDF-1.4 fake'),
|
|
126
|
+
COMPANY_DETAILS: {},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stubRequire = ((id: string) => {
|
|
130
|
+
if (id.indexOf('data-access') !== -1) return dataAccessStub
|
|
131
|
+
if (id.indexOf('pdf-generator') !== -1) return pdfGeneratorStub
|
|
132
|
+
if (id === 'pg') return { Client: class {} }
|
|
133
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
134
|
+
return require(id)
|
|
135
|
+
}) as unknown as NodeRequire
|
|
136
|
+
|
|
137
|
+
const factory = new Function(
|
|
138
|
+
'require',
|
|
139
|
+
'module',
|
|
140
|
+
'exports',
|
|
141
|
+
`${code}; return module.exports;`
|
|
142
|
+
) as (
|
|
143
|
+
req: NodeRequire,
|
|
144
|
+
mod: { exports: unknown },
|
|
145
|
+
exp: unknown
|
|
146
|
+
) => (req: unknown, res: unknown) => Promise<void>
|
|
147
|
+
|
|
148
|
+
const moduleObject = { exports: {} as unknown }
|
|
149
|
+
const handler = factory(stubRequire, moduleObject, moduleObject.exports)
|
|
150
|
+
|
|
151
|
+
let status = 0
|
|
152
|
+
let payload: Record<string, unknown> = {}
|
|
153
|
+
const res = {
|
|
154
|
+
status(statusCode: number) {
|
|
155
|
+
status = statusCode
|
|
156
|
+
return this
|
|
157
|
+
},
|
|
158
|
+
json(value: Record<string, unknown>) {
|
|
159
|
+
payload = value
|
|
160
|
+
return this
|
|
161
|
+
},
|
|
162
|
+
setHeader() {
|
|
163
|
+
/* not used by this route */
|
|
164
|
+
},
|
|
165
|
+
end() {
|
|
166
|
+
/* not used by this route */
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await handler({ method: 'POST', headers: { host: 'localhost:3000' }, body: options.body }, res)
|
|
171
|
+
|
|
172
|
+
return { status, payload, insertedItems, reads }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
describe('/api/invoices/generate — waits for the order to be fully written', () => {
|
|
176
|
+
const originalFetch = globalThis.fetch
|
|
177
|
+
const originalConnString = process.env.TELEPORT_DB_CONNECTION_STRING
|
|
178
|
+
const originalDatabaseUrl = process.env.DATABASE_URL
|
|
179
|
+
|
|
180
|
+
beforeAll(() => {
|
|
181
|
+
// The route self-fetches the runtime-storage proxy and mirrors onto
|
|
182
|
+
// `teleport_orders`; neither is under test and neither may reach the
|
|
183
|
+
// network from a unit test.
|
|
184
|
+
globalThis.fetch = (async () => {
|
|
185
|
+
throw new Error('runtime storage not configured in tests')
|
|
186
|
+
}) as unknown as typeof fetch
|
|
187
|
+
delete process.env.TELEPORT_DB_CONNECTION_STRING
|
|
188
|
+
delete process.env.DATABASE_URL
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
afterAll(() => {
|
|
192
|
+
globalThis.fetch = originalFetch
|
|
193
|
+
if (originalConnString !== undefined) {
|
|
194
|
+
process.env.TELEPORT_DB_CONNECTION_STRING = originalConnString
|
|
195
|
+
}
|
|
196
|
+
if (originalDatabaseUrl !== undefined) {
|
|
197
|
+
process.env.DATABASE_URL = originalDatabaseUrl
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('invoices every line of an order whose items are still being inserted', async () => {
|
|
202
|
+
// The exact shape of the reported bug: one row visible on the first read,
|
|
203
|
+
// the rest landing while the endpoint runs. `expectedItemCount` is what
|
|
204
|
+
// the order-notification route forwards from the cart it was handed.
|
|
205
|
+
const run = await runHandler({
|
|
206
|
+
body: { orderId: 'order-1', expectedItemCount: 3 },
|
|
207
|
+
visibleItemsPerRead: (read) => Math.min(read, 3),
|
|
208
|
+
orderNumberAfterRead: Infinity,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(run.status).toBe(200)
|
|
212
|
+
expect(run.insertedItems).toHaveLength(3)
|
|
213
|
+
expect(run.insertedItems.map((item) => (item as { name: string }).name)).toEqual([
|
|
214
|
+
'Linen Table Runner',
|
|
215
|
+
'Ceramic Dinner Plate Set',
|
|
216
|
+
'Wireless Bluetooth Speaker',
|
|
217
|
+
])
|
|
218
|
+
// 90 + 104.97 + 99.98 = 294.95 net, +10% VAT = 324.45, +150 delivery.
|
|
219
|
+
expect(run.payload.total).toBe(474.45)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('accepts the order as whole once checkout has backfilled the order number', async () => {
|
|
223
|
+
// No `expectedItemCount` — the webhook-driven callers send only an
|
|
224
|
+
// orderId. `teleport_orders.order_number` is written after the item loop,
|
|
225
|
+
// so its presence is the endpoint's own proof that no line is missing.
|
|
226
|
+
const run = await runHandler({
|
|
227
|
+
body: { orderId: 'order-1' },
|
|
228
|
+
visibleItemsPerRead: (read) => Math.min(read, 3),
|
|
229
|
+
orderNumberAfterRead: 3,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(run.status).toBe(200)
|
|
233
|
+
expect(run.insertedItems).toHaveLength(3)
|
|
234
|
+
expect(run.reads).toBe(3)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('does not poll an order that is already complete on the first read', async () => {
|
|
238
|
+
// The common case — every payment-webhook invoice. It must cost exactly
|
|
239
|
+
// one query and no added latency.
|
|
240
|
+
const run = await runHandler({
|
|
241
|
+
body: { orderId: 'order-1' },
|
|
242
|
+
visibleItemsPerRead: () => 3,
|
|
243
|
+
orderNumberAfterRead: 1,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
expect(run.status).toBe(200)
|
|
247
|
+
expect(run.insertedItems).toHaveLength(3)
|
|
248
|
+
expect(run.reads).toBe(1)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('does not wait at all when the caller supplied its own line items', async () => {
|
|
252
|
+
const run = await runHandler({
|
|
253
|
+
body: {
|
|
254
|
+
orderId: 'order-1',
|
|
255
|
+
items: [{ name: 'Handpicked line', quantity: 1, unitPrice: 10, totalPrice: 10 }],
|
|
256
|
+
},
|
|
257
|
+
visibleItemsPerRead: () => 0,
|
|
258
|
+
orderNumberAfterRead: Infinity,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
expect(run.status).toBe(200)
|
|
262
|
+
expect(run.reads).toBe(1)
|
|
263
|
+
expect(run.insertedItems).toHaveLength(1)
|
|
264
|
+
expect((run.insertedItems[0] as { name: string }).name).toBe('Handpicked line')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('still rejects an order that never gets any line items', async () => {
|
|
268
|
+
const run = await runHandler({
|
|
269
|
+
body: { orderId: 'order-1', expectedItemCount: 3 },
|
|
270
|
+
visibleItemsPerRead: () => 0,
|
|
271
|
+
orderNumberAfterRead: 1,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expect(run.status).toBe(400)
|
|
275
|
+
expect(run.payload.error).toBe('At least one item is required')
|
|
276
|
+
}, 15000)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('/api/invoices/generate — settle contract (emitted source)', () => {
|
|
280
|
+
const route = generateInvoiceGenerateRouteCode(FAKE_SETTINGS)
|
|
281
|
+
|
|
282
|
+
it('reads the line items through the settling helper, never a bare query', () => {
|
|
283
|
+
expect(route).toContain('async function hydrateOrderWhenSettled(orderId, expectedItemCount)')
|
|
284
|
+
expect(route).toContain('await hydrateOrderWhenSettled(body.orderId, expectedItemCount)')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('treats a populated order_number as proof the item loop finished', () => {
|
|
288
|
+
// Checkout writes it in "Mark Order As Cash On Delivery Confirmed" /
|
|
289
|
+
// "Set Order Number Before Payment Redirect", both downstream of the loop.
|
|
290
|
+
expect(route).toContain('var orderNumber = hydrated.order.order_number;')
|
|
291
|
+
expect(route).toContain(
|
|
292
|
+
'if (count > 0 && orderNumber != null && String(orderNumber).length > 0) {'
|
|
293
|
+
)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('bounds the wait so a stuck order still produces an invoice', () => {
|
|
297
|
+
expect(route).toContain('var SETTLE_TIMEOUT_MS = 6000;')
|
|
298
|
+
expect(route).toContain('did not settle within ')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('warns when the invoice total disagrees with the amount charged', () => {
|
|
302
|
+
// The tripwire that would have caught this the first time: the invoice is
|
|
303
|
+
// summed from line items, `teleport_orders.total_amount` is what the buyer
|
|
304
|
+
// actually paid, and a missing line makes them disagree.
|
|
305
|
+
expect(route).toContain('var orderTotalAmount = Number(orderShippingSource.total_amount);')
|
|
306
|
+
expect(route).toContain('[invoice] Total mismatch for order ')
|
|
307
|
+
})
|
|
308
|
+
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-routes-code.d.ts","sourceRoot":"","sources":["../../../src/invoice/api-routes-code.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAEhE,eAAO,MAAM,gCAAgC,aAAc,mBAAmB,KAAG,
|
|
1
|
+
{"version":3,"file":"api-routes-code.d.ts","sourceRoot":"","sources":["../../../src/invoice/api-routes-code.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAEhE,eAAO,MAAM,gCAAgC,aAAc,mBAAmB,KAAG,MAsmBhF,CAAA;AAED,eAAO,MAAM,2BAA2B,QAAO,MAsD9C,CAAA"}
|
|
@@ -93,6 +93,100 @@ async function uploadInvoicePdfToRuntimeStorage(pdfBuffer, fileName, baseUrl) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Waiting for the order's line items to be fully written.
|
|
98
|
+
//
|
|
99
|
+
// This endpoint is reached BEFORE checkout has finished writing the order.
|
|
100
|
+
// \`/api/ecommerce/order-notification\` is fire-and-forget'd by the runtime
|
|
101
|
+
// \`data-create-item\` handler the moment the \`teleport_orders\` row lands —
|
|
102
|
+
// which is upstream of the checkout workflow's "Loop Over Each Cart Item",
|
|
103
|
+
// the loop that inserts \`teleport_order_items\` one HTTP round-trip at a
|
|
104
|
+
// time. Hydrating on arrival therefore snapshots a HALF-WRITTEN order: a
|
|
105
|
+
// real 3-line order was invoiced with a single line and a total 225.45
|
|
106
|
+
// short of what the buyer was charged, because the other two rows were
|
|
107
|
+
// still in flight when the hydration query ran.
|
|
108
|
+
//
|
|
109
|
+
// So don't race it — wait for the order to settle, then hydrate. Three exit
|
|
110
|
+
// conditions, cheapest first:
|
|
111
|
+
//
|
|
112
|
+
// 1. \`expectedItemCount\` — the caller's own cart-line count — is reached.
|
|
113
|
+
// The order-notification route passes it, so the racing path is exact.
|
|
114
|
+
// 2. \`teleport_orders.order_number\` is populated. Checkout backfills the
|
|
115
|
+
// order number only AFTER the item loop has run to completion (the COD
|
|
116
|
+
// branch does it in "Mark Order As Cash On Delivery Confirmed", the
|
|
117
|
+
// online-payment branch in "Set Order Number Before Payment Redirect"),
|
|
118
|
+
// so a non-empty order number proves every line has been written.
|
|
119
|
+
// 3. The row count is identical across \`SETTLE_STABLE_READS\` consecutive
|
|
120
|
+
// reads — the fallback for orders written by a flow that never sets an
|
|
121
|
+
// order number.
|
|
122
|
+
//
|
|
123
|
+
// An order that is already complete — every webhook-driven call, and any
|
|
124
|
+
// hand-built admin call — satisfies (1) or (2) on the FIRST read, so the
|
|
125
|
+
// only request that ever pays for a poll is the one that would otherwise
|
|
126
|
+
// have shipped a wrong invoice.
|
|
127
|
+
var SETTLE_TIMEOUT_MS = 6000;
|
|
128
|
+
var SETTLE_POLL_MS = 400;
|
|
129
|
+
var SETTLE_STABLE_READS = 3;
|
|
130
|
+
|
|
131
|
+
function sleepMs(ms) {
|
|
132
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function hydrateOrderWhenSettled(orderId, expectedItemCount) {
|
|
136
|
+
var deadline = Date.now() + SETTLE_TIMEOUT_MS;
|
|
137
|
+
var lastCount = -1;
|
|
138
|
+
var repeats = 0;
|
|
139
|
+
var attempts = 0;
|
|
140
|
+
|
|
141
|
+
while (true) {
|
|
142
|
+
var hydrated = await dataAccess.getOrderWithItems(orderId);
|
|
143
|
+
attempts += 1;
|
|
144
|
+
if (!hydrated || !hydrated.order) {
|
|
145
|
+
// Nothing to wait for — the order id doesn't resolve. The caller logs
|
|
146
|
+
// this as a miss and falls back to its own body.* fields.
|
|
147
|
+
return hydrated;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
var rows = Array.isArray(hydrated.items) ? hydrated.items : [];
|
|
151
|
+
var count = rows.length;
|
|
152
|
+
|
|
153
|
+
if (expectedItemCount > 0 && count >= expectedItemCount) {
|
|
154
|
+
if (attempts > 1) {
|
|
155
|
+
console.info('[invoice] Order ' + orderId + ' settled at ' + count + ' line(s) after ' + attempts + ' read(s) (expected ' + expectedItemCount + ')');
|
|
156
|
+
}
|
|
157
|
+
return hydrated;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
var orderNumber = hydrated.order.order_number;
|
|
161
|
+
if (count > 0 && orderNumber != null && String(orderNumber).length > 0) {
|
|
162
|
+
if (attempts > 1) {
|
|
163
|
+
console.info('[invoice] Order ' + orderId + ' settled at ' + count + ' line(s) after ' + attempts + ' read(s) (order_number=' + orderNumber + ')');
|
|
164
|
+
}
|
|
165
|
+
return hydrated;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (count === lastCount) {
|
|
169
|
+
repeats += 1;
|
|
170
|
+
} else {
|
|
171
|
+
lastCount = count;
|
|
172
|
+
repeats = 1;
|
|
173
|
+
}
|
|
174
|
+
if (count > 0 && repeats >= SETTLE_STABLE_READS) {
|
|
175
|
+
console.info('[invoice] Order ' + orderId + ' settled at ' + count + ' line(s) — row count stable across ' + repeats + ' reads');
|
|
176
|
+
return hydrated;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (Date.now() >= deadline) {
|
|
180
|
+
console.warn('[invoice] Order ' + orderId + ' did not settle within ' + SETTLE_TIMEOUT_MS +
|
|
181
|
+
'ms — invoicing the ' + count + ' line(s) visible now' +
|
|
182
|
+
(expectedItemCount > 0 ? ' (expected ' + expectedItemCount + ')' : ''));
|
|
183
|
+
return hydrated;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await sleepMs(SETTLE_POLL_MS);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
96
190
|
var CURRENCY_SYMBOLS = {
|
|
97
191
|
'USD': '$', 'EUR': '\\u20AC', 'GBP': '\\u00A3', 'JPY': '\\u00A5',
|
|
98
192
|
'CAD': 'C$', 'AUD': 'A$', 'CHF': 'CHF ', 'CNY': '\\u00A5',
|
|
@@ -129,11 +223,23 @@ module.exports = async function handler(req, res) {
|
|
|
129
223
|
return;
|
|
130
224
|
}
|
|
131
225
|
|
|
226
|
+
var callerSuppliedItems = Array.isArray(body.items) && body.items.length > 0;
|
|
227
|
+
|
|
228
|
+
// How many line items the caller believes this order has. Used only to
|
|
229
|
+
// stop \`hydrateOrderWhenSettled\` polling the moment the order is whole —
|
|
230
|
+
// never to reject or pad the invoice, so a wrong count can at worst make
|
|
231
|
+
// us wait a little longer and fall through to the other exit conditions.
|
|
232
|
+
var expectedItemCount = Number(body.expectedItemCount);
|
|
233
|
+
if (!isFinite(expectedItemCount) || expectedItemCount < 0) {
|
|
234
|
+
expectedItemCount = 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
132
237
|
console.info('[invoice] === /api/invoices/generate === baseUrl=' + __baseUrl);
|
|
133
238
|
console.info('[invoice] Request received:', {
|
|
134
239
|
orderId: body.orderId || null,
|
|
135
|
-
hasBodyItems:
|
|
240
|
+
hasBodyItems: callerSuppliedItems,
|
|
136
241
|
bodyItemCount: Array.isArray(body.items) ? body.items.length : 0,
|
|
242
|
+
expectedItemCount: expectedItemCount,
|
|
137
243
|
hasCustomerEmail: !!body.customerEmail,
|
|
138
244
|
requestedCurrency: body.currency || null,
|
|
139
245
|
});
|
|
@@ -148,11 +254,19 @@ module.exports = async function handler(req, res) {
|
|
|
148
254
|
// provide wins, so hand-built admin-side calls (or future clients)
|
|
149
255
|
// can still override the customer/company/notes/etc. without being
|
|
150
256
|
// forced to round-trip the DB themselves.
|
|
257
|
+
//
|
|
258
|
+
// The line items are read through \`hydrateOrderWhenSettled\`, which waits
|
|
259
|
+
// for checkout to finish writing them — see the comment on that helper
|
|
260
|
+
// for why an unguarded read snapshots a half-written order. A caller that
|
|
261
|
+
// brought its own \`items\` array doesn't depend on the DB rows at all, so
|
|
262
|
+
// that path takes the plain single read and skips the wait entirely.
|
|
151
263
|
var hydratedOrder = null;
|
|
152
264
|
var hydratedItems = [];
|
|
153
265
|
if (body.orderId) {
|
|
154
266
|
try {
|
|
155
|
-
var hydrated =
|
|
267
|
+
var hydrated = callerSuppliedItems
|
|
268
|
+
? await dataAccess.getOrderWithItems(body.orderId)
|
|
269
|
+
: await hydrateOrderWhenSettled(body.orderId, expectedItemCount);
|
|
156
270
|
if (hydrated && hydrated.order) {
|
|
157
271
|
hydratedOrder = hydrated.order;
|
|
158
272
|
hydratedItems = Array.isArray(hydrated.items) ? hydrated.items : [];
|
|
@@ -167,7 +281,7 @@ module.exports = async function handler(req, res) {
|
|
|
167
281
|
console.info('[invoice] No orderId in request — skipping DB hydration, using body.* fields verbatim');
|
|
168
282
|
}
|
|
169
283
|
|
|
170
|
-
var items =
|
|
284
|
+
var items = callerSuppliedItems
|
|
171
285
|
? body.items
|
|
172
286
|
: hydratedItems.map(function (row) {
|
|
173
287
|
return {
|
|
@@ -254,6 +368,21 @@ module.exports = async function handler(req, res) {
|
|
|
254
368
|
}
|
|
255
369
|
var total = goodsTotal + shippingAmount;
|
|
256
370
|
|
|
371
|
+
// Cross-check against the amount the order was actually placed for. The
|
|
372
|
+
// invoice is built from line items, the order carries the charged total,
|
|
373
|
+
// and the two must agree — when they don't, the invoice is being written
|
|
374
|
+
// from an incomplete set of lines (exactly what a lost race against
|
|
375
|
+
// checkout's item loop looks like). Log-only: a mismatch must never stop
|
|
376
|
+
// the merchant getting an invoice, but it has to be visible in the
|
|
377
|
+
// function logs instead of silently shipping a short bill.
|
|
378
|
+
var orderTotalAmount = Number(orderShippingSource.total_amount);
|
|
379
|
+
if (isFinite(orderTotalAmount) && orderTotalAmount > 0 && Math.abs(orderTotalAmount - total) > 0.02) {
|
|
380
|
+
console.warn('[invoice] Total mismatch for order ' + (body.orderId || '(none)') +
|
|
381
|
+
' — invoice total ' + (Math.round(total * 100) / 100) +
|
|
382
|
+
' vs teleport_orders.total_amount ' + orderTotalAmount +
|
|
383
|
+
' across ' + items.length + ' line item(s). The invoice may be missing lines.');
|
|
384
|
+
}
|
|
385
|
+
|
|
257
386
|
var nextNumber = await dataAccess.getNextInvoiceNumber(INVOICE_PREFIX);
|
|
258
387
|
var invoiceNumber = INVOICE_PREFIX + String(nextNumber).padStart(4, '0');
|
|
259
388
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-routes-code.js","sourceRoot":"","sources":["../../../src/invoice/api-routes-code.ts"],"names":[],"mappings":";;;AAEO,MAAM,gCAAgC,GAAG,CAAC,QAA6B,EAAU,EAAE;;IACxF,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,IAAI,MAAM,CAAA;IAC/C,MAAM,cAAc,GAAG,QAAQ,CAAC,cAAc,IAAI,CAAC,CAAA;IACnD,MAAM,YAAY,GAAG,QAAQ,CAAC,YAAY,KAAK,IAAI,CAAA;IACnD,MAAM,kBAAkB,GAAG,QAAQ,CAAC,kBAAkB,KAAK,IAAI,CAAA;IAC/D,MAAM,YAAY,GAAG,QAAQ,CAAC,qBAAqB,KAAK,KAAK,CAAA;IAC7D,MAAM,YAAY,GAAG,CAAA,MAAA,QAAQ,CAAC,aAAa,0CAAE,OAAO,MAAK,IAAI,CAAA;IAC7D,MAAM,oBAAoB,GAAG,CAAA,MAAA,QAAQ,CAAC,QAAQ,0CAAE,QAAQ;QACtD,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC5C,CAAC,CAAC,MAAM,CAAA;IAEV,OAAO;;;;;;;;;EASP,YAAY,CAAC,CAAC,CAAC,oEAAoE,CAAC,CAAC,CAAC,EAAE;;uBAEnE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;yBACpB,cAAc;;sBAEjB,YAAY;8BACJ,kBAAkB;0BACtB,oBAAoB
|
|
1
|
+
{"version":3,"file":"api-routes-code.js","sourceRoot":"","sources":["../../../src/invoice/api-routes-code.ts"],"names":[],"mappings":";;;AAEO,MAAM,gCAAgC,GAAG,CAAC,QAA6B,EAAU,EAAE;;IACxF,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,IAAI,MAAM,CAAA;IAC/C,MAAM,cAAc,GAAG,QAAQ,CAAC,cAAc,IAAI,CAAC,CAAA;IACnD,MAAM,YAAY,GAAG,QAAQ,CAAC,YAAY,KAAK,IAAI,CAAA;IACnD,MAAM,kBAAkB,GAAG,QAAQ,CAAC,kBAAkB,KAAK,IAAI,CAAA;IAC/D,MAAM,YAAY,GAAG,QAAQ,CAAC,qBAAqB,KAAK,KAAK,CAAA;IAC7D,MAAM,YAAY,GAAG,CAAA,MAAA,QAAQ,CAAC,aAAa,0CAAE,OAAO,MAAK,IAAI,CAAA;IAC7D,MAAM,oBAAoB,GAAG,CAAA,MAAA,QAAQ,CAAC,QAAQ,0CAAE,QAAQ;QACtD,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAC5C,CAAC,CAAC,MAAM,CAAA;IAEV,OAAO;;;;;;;;;EASP,YAAY,CAAC,CAAC,CAAC,oEAAoE,CAAC,CAAC,CAAC,EAAE;;uBAEnE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;yBACpB,cAAc;;sBAEjB,YAAY;8BACJ,kBAAkB;0BACtB,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAohB5C,YAAY;QACV,CAAC,CAAC;;;;;;;;;;;;;;;CAeL;QACG,CAAC,CAAC;;CAGN;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,CAAA;AACD,CAAC,CAAA;AAtmBY,QAAA,gCAAgC,oCAsmB5C;AAEM,MAAM,2BAA2B,GAAG,GAAW,EAAE;IACtD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDR,CAAA;AACD,CAAC,CAAA;AAtDY,QAAA,2BAA2B,+BAsDvC"}
|