paypal-unit.test.js
1 /** 2 * PayPal Payment Integration — Additional Unit Tests 3 * 4 * Covers gaps in paypal.js not addressed by existing paypal.test.js: 5 * - testPrice override in createPaymentOrder (lines 83-87) 6 * - verifyPayment with null payer / no name object 7 * - capturePayment error formatting edge cases (no details, no name) 8 * - refundPayment with no reason (empty body path) 9 * - refundPayment amount parsing with missing amount field 10 * - generatePaymentMessage SMS branding and content assertions 11 * 12 * Uses node:test + node:assert/strict with mocked axios. 13 */ 14 15 import { describe, test, mock, beforeEach } from 'node:test'; 16 import assert from 'node:assert/strict'; 17 18 // ---- Mock axios ---- 19 const axiosPostMock = mock.fn(); 20 const axiosGetMock = mock.fn(); 21 22 mock.module('axios', { 23 defaultExport: { 24 post: axiosPostMock, 25 get: axiosGetMock, 26 }, 27 }); 28 29 // Mock dotenv 30 mock.module('dotenv', { 31 defaultExport: { config: () => {} }, 32 namedExports: { config: () => {} }, 33 }); 34 35 // Mock country-pricing with currencySymbol for testPrice path 36 mock.module('../../src/utils/country-pricing.js', { 37 namedExports: { 38 getPrice: countryCode => { 39 const pricing = { 40 US: { 41 currency: 'USD', 42 currencySymbol: '$', 43 priceLocal: 297, 44 priceUsd: 297, 45 formattedPrice: '$297.00 USD', 46 exchangeRate: 1, 47 }, 48 AU: { 49 currency: 'AUD', 50 currencySymbol: 'A$', 51 priceLocal: 447, 52 priceUsd: 297, 53 formattedPrice: 'A$447.00 AUD', 54 exchangeRate: 1.505, 55 }, 56 GB: { 57 currency: 'GBP', 58 currencySymbol: '\u00a3', 59 priceLocal: 159, 60 priceUsd: 199, 61 formattedPrice: '\u00a3159.00 GBP', 62 exchangeRate: 0.798, 63 }, 64 }; 65 return pricing[countryCode] || null; 66 }, 67 }, 68 }); 69 70 // Set env before importing 71 process.env.PAYPAL_CLIENT_ID = 'test-client-id'; 72 process.env.PAYPAL_CLIENT_SECRET = 'test-client-secret'; 73 process.env.PAYPAL_MODE = 'sandbox'; 74 process.env.PAYPAL_BRAND_NAME = 'Audit&Fix'; 75 process.env.BASE_URL = 'https://test.example.com'; 76 process.env.LOGS_DIR = '/tmp/test-logs'; 77 process.env.NODE_ENV = 'test'; 78 79 const { createPaymentOrder, capturePayment, verifyPayment, refundPayment, generatePaymentMessage } = 80 await import('../../src/payment/paypal.js'); 81 82 // ---- Helpers ---- 83 function mockAccessToken() { 84 return { data: { access_token: 'mock-token-unit', expires_in: 3600 } }; 85 } 86 87 function mockOrderResponse(id, overrides = {}) { 88 return { 89 data: { 90 id, 91 status: 'PAYER_ACTION_REQUIRED', 92 links: [{ rel: 'payer-action', href: `https://sandbox.paypal.com/pay?token=${id}` }], 93 ...overrides, 94 }, 95 }; 96 } 97 98 // ---- Tests ---- 99 100 describe('paypal.js — additional unit tests', () => { 101 beforeEach(() => { 102 axiosPostMock.mock.resetCalls(); 103 axiosGetMock.mock.resetCalls(); 104 axiosPostMock.mock.mockImplementation(async () => { 105 throw new Error('axiosPost mock not configured'); 106 }); 107 axiosGetMock.mock.mockImplementation(async () => { 108 throw new Error('axiosGet mock not configured'); 109 }); 110 }); 111 112 // ---- testPrice override (lines 83-87) ---- 113 114 describe('createPaymentOrder() — testPrice override', () => { 115 test('overrides pricing with testPrice when provided', async () => { 116 axiosPostMock.mock.mockImplementation(async url => { 117 if (url.includes('/oauth2/token')) return mockAccessToken(); 118 return mockOrderResponse('ORDER_TEST_PRICE'); 119 }); 120 121 const result = await createPaymentOrder({ 122 domain: 'test-price.com', 123 email: 'buyer@test.com', 124 siteId: 1, 125 conversationId: 1, 126 countryCode: 'US', 127 testPrice: 1.0, 128 }); 129 130 // Verify the order was created with testPrice amount 131 const orderCall = axiosPostMock.mock.calls.find(c => 132 c.arguments[0].includes('/v2/checkout/orders') 133 ); 134 assert.ok(orderCall, 'Should make order creation call'); 135 const body = orderCall.arguments[1]; 136 assert.equal(body.purchase_units[0].amount.value, '1.00'); 137 assert.equal(body.purchase_units[0].amount.currency_code, 'USD'); 138 assert.equal(body.purchase_units[0].items[0].unit_amount.value, '1.00'); 139 140 // Result amount should reflect the test price 141 assert.equal(result.amount, 1.0); 142 }); 143 144 test('testPrice=0 is treated as testPrice override (falsy but not null)', async () => { 145 // testPrice=0 should NOT trigger override because 0 is falsy 146 // and the code checks `testPrice !== null && testPrice !== undefined` 147 // So 0 SHOULD trigger the override path 148 axiosPostMock.mock.mockImplementation(async url => { 149 if (url.includes('/oauth2/token')) return mockAccessToken(); 150 return mockOrderResponse('ORDER_ZERO_PRICE'); 151 }); 152 153 const result = await createPaymentOrder({ 154 domain: 'zero-price.com', 155 email: 'buyer@test.com', 156 siteId: 1, 157 conversationId: 1, 158 countryCode: 'US', 159 testPrice: 0, 160 }); 161 162 const orderCall = axiosPostMock.mock.calls.find(c => 163 c.arguments[0].includes('/v2/checkout/orders') 164 ); 165 const body = orderCall.arguments[1]; 166 assert.equal(body.purchase_units[0].amount.value, '0.00'); 167 }); 168 169 test('testPrice=null does not override (uses default pricing)', async () => { 170 axiosPostMock.mock.mockImplementation(async url => { 171 if (url.includes('/oauth2/token')) return mockAccessToken(); 172 return mockOrderResponse('ORDER_NULL_PRICE'); 173 }); 174 175 const result = await createPaymentOrder({ 176 domain: 'null-price.com', 177 email: 'buyer@test.com', 178 siteId: 1, 179 conversationId: 1, 180 countryCode: 'US', 181 testPrice: null, 182 }); 183 184 const orderCall = axiosPostMock.mock.calls.find(c => 185 c.arguments[0].includes('/v2/checkout/orders') 186 ); 187 const body = orderCall.arguments[1]; 188 assert.equal(body.purchase_units[0].amount.value, '297.00', 'Should use default US price'); 189 }); 190 191 test('testPrice=undefined does not override (uses default pricing)', async () => { 192 axiosPostMock.mock.mockImplementation(async url => { 193 if (url.includes('/oauth2/token')) return mockAccessToken(); 194 return mockOrderResponse('ORDER_UNDEF_PRICE'); 195 }); 196 197 const result = await createPaymentOrder({ 198 domain: 'undef-price.com', 199 email: 'buyer@test.com', 200 siteId: 1, 201 conversationId: 1, 202 countryCode: 'US', 203 testPrice: undefined, 204 }); 205 206 const orderCall = axiosPostMock.mock.calls.find(c => 207 c.arguments[0].includes('/v2/checkout/orders') 208 ); 209 const body = orderCall.arguments[1]; 210 assert.equal(body.purchase_units[0].amount.value, '297.00'); 211 }); 212 213 test('testPrice with non-US country uses correct currency but overridden amount', async () => { 214 axiosPostMock.mock.mockImplementation(async url => { 215 if (url.includes('/oauth2/token')) return mockAccessToken(); 216 return mockOrderResponse('ORDER_AU_TEST'); 217 }); 218 219 await createPaymentOrder({ 220 domain: 'au-test.com.au', 221 email: 'buyer@test.com', 222 siteId: 1, 223 conversationId: 1, 224 countryCode: 'AU', 225 testPrice: 5.0, 226 }); 227 228 const orderCall = axiosPostMock.mock.calls.find(c => 229 c.arguments[0].includes('/v2/checkout/orders') 230 ); 231 const body = orderCall.arguments[1]; 232 // Should use AUD currency but the overridden price 233 assert.equal(body.purchase_units[0].amount.currency_code, 'AUD'); 234 assert.equal(body.purchase_units[0].amount.value, '5.00'); 235 }); 236 }); 237 238 // ---- verifyPayment edge cases ---- 239 240 describe('verifyPayment() — edge cases', () => { 241 test('handles payer with no name object at all', async () => { 242 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 243 axiosGetMock.mock.mockImplementation(async () => ({ 244 data: { 245 id: 'ORDER_NO_NAME', 246 status: 'COMPLETED', 247 payer: { email_address: 'anon@test.com' }, 248 purchase_units: [ 249 { amount: { value: '297.00', currency_code: 'USD' }, reference_id: 'site_1_conv_1' }, 250 ], 251 create_time: '2025-01-01T12:00:00Z', 252 update_time: '2025-01-01T12:01:00Z', 253 }, 254 })); 255 256 const result = await verifyPayment('ORDER_NO_NAME'); 257 258 assert.equal(result.isPaid, true); 259 assert.equal(result.payerEmail, 'anon@test.com'); 260 assert.equal(result.payerName, null, 'Should be null when no name object'); 261 }); 262 263 test('handles completely null payer object', async () => { 264 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 265 axiosGetMock.mock.mockImplementation(async () => ({ 266 data: { 267 id: 'ORDER_NULL_PAYER', 268 status: 'APPROVED', 269 payer: null, 270 purchase_units: [ 271 { amount: { value: '159.00', currency_code: 'GBP' }, reference_id: 'site_5_conv_10' }, 272 ], 273 create_time: '2025-01-01T12:00:00Z', 274 update_time: '2025-01-01T12:00:00Z', 275 }, 276 })); 277 278 const result = await verifyPayment('ORDER_NULL_PAYER'); 279 280 assert.equal(result.isPaid, false); 281 assert.equal(result.payerEmail, undefined); 282 assert.equal(result.payerName, null); 283 assert.equal(result.amount, 159); 284 assert.equal(result.currency, 'GBP'); 285 assert.equal(result.referenceId, 'site_5_conv_10'); 286 }); 287 288 test('returns correct createTime and updateTime', async () => { 289 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 290 axiosGetMock.mock.mockImplementation(async () => ({ 291 data: { 292 id: 'ORDER_TIMES', 293 status: 'COMPLETED', 294 payer: { email_address: 'time@test.com', name: { given_name: 'Tim', surname: 'Er' } }, 295 purchase_units: [ 296 { amount: { value: '297.00', currency_code: 'USD' }, reference_id: 'site_1_conv_1' }, 297 ], 298 create_time: '2025-06-15T08:30:00Z', 299 update_time: '2025-06-15T08:35:00Z', 300 }, 301 })); 302 303 const result = await verifyPayment('ORDER_TIMES'); 304 305 assert.equal(result.createTime, '2025-06-15T08:30:00Z'); 306 assert.equal(result.updateTime, '2025-06-15T08:35:00Z'); 307 }); 308 309 test('throws with generic error message when no response data', async () => { 310 axiosPostMock.mock.mockImplementation(async () => mockAccessToken()); 311 axiosGetMock.mock.mockImplementation(async () => { 312 throw new Error('ECONNRESET'); 313 }); 314 315 await assert.rejects( 316 () => verifyPayment('ORDER_NETWORK_FAIL'), 317 err => { 318 assert.ok(err.message.includes('PayPal verification failed')); 319 assert.ok(err.message.includes('ECONNRESET')); 320 return true; 321 } 322 ); 323 }); 324 }); 325 326 // ---- capturePayment error formatting edge cases ---- 327 328 describe('capturePayment() — error formatting edge cases', () => { 329 test('formats error with name but no details array', async () => { 330 axiosPostMock.mock.mockImplementation(async url => { 331 if (url.includes('/oauth2/token')) return mockAccessToken(); 332 const err = new Error('Request failed'); 333 err.response = { 334 data: { 335 name: 'INTERNAL_SERVER_ERROR', 336 message: 'An internal server error has occurred', 337 // No details array 338 }, 339 }; 340 throw err; 341 }); 342 343 await assert.rejects( 344 () => capturePayment('ORDER_NO_DETAILS'), 345 err => { 346 assert.ok(err.message.includes('PayPal capture failed')); 347 assert.ok(err.message.includes('INTERNAL_SERVER_ERROR')); 348 assert.ok(err.message.includes('An internal server error')); 349 // Should not include "undefined" from missing details 350 assert.ok(!err.message.includes('undefined')); 351 return true; 352 } 353 ); 354 }); 355 356 test('formats error with no name and no details', async () => { 357 axiosPostMock.mock.mockImplementation(async url => { 358 if (url.includes('/oauth2/token')) return mockAccessToken(); 359 const err = new Error('Request failed with status 500'); 360 err.response = { 361 data: { 362 message: 'Something went wrong', 363 }, 364 }; 365 throw err; 366 }); 367 368 await assert.rejects( 369 () => capturePayment('ORDER_BARE_ERROR'), 370 err => { 371 assert.ok(err.message.includes('PayPal capture failed')); 372 assert.ok(err.message.includes('Something went wrong')); 373 return true; 374 } 375 ); 376 }); 377 378 test('formats error with no response data at all (network error)', async () => { 379 axiosPostMock.mock.mockImplementation(async url => { 380 if (url.includes('/oauth2/token')) return mockAccessToken(); 381 throw new Error('ETIMEDOUT'); 382 }); 383 384 await assert.rejects( 385 () => capturePayment('ORDER_TIMEOUT'), 386 err => { 387 assert.ok(err.message.includes('PayPal capture failed')); 388 assert.ok(err.message.includes('ETIMEDOUT')); 389 return true; 390 } 391 ); 392 }); 393 394 test('formats error with empty details array', async () => { 395 axiosPostMock.mock.mockImplementation(async url => { 396 if (url.includes('/oauth2/token')) return mockAccessToken(); 397 const err = new Error('Request failed'); 398 err.response = { 399 data: { 400 name: 'UNPROCESSABLE_ENTITY', 401 message: 'The requested action could not be performed', 402 details: [], // Empty array 403 }, 404 }; 405 throw err; 406 }); 407 408 await assert.rejects( 409 () => capturePayment('ORDER_EMPTY_DETAILS'), 410 err => { 411 assert.ok(err.message.includes('UNPROCESSABLE_ENTITY')); 412 return true; 413 } 414 ); 415 }); 416 }); 417 418 // ---- refundPayment edge cases ---- 419 420 describe('refundPayment() — edge cases', () => { 421 test('sends empty body when no reason provided', async () => { 422 let capturedBody = null; 423 axiosPostMock.mock.mockImplementation(async (url, body) => { 424 if (url.includes('/oauth2/token')) return mockAccessToken(); 425 capturedBody = body; 426 return { 427 data: { 428 id: 'REFUND_NO_REASON', 429 status: 'COMPLETED', 430 amount: { value: '297.00', currency_code: 'USD' }, 431 }, 432 }; 433 }); 434 435 const result = await refundPayment('CAP_NO_REASON'); 436 437 assert.equal(result.success, true); 438 assert.deepEqual(capturedBody, {}, 'Should send empty body when no reason'); 439 }); 440 441 test('sends reason as note_to_payer when provided', async () => { 442 let capturedBody = null; 443 axiosPostMock.mock.mockImplementation(async (url, body) => { 444 if (url.includes('/oauth2/token')) return mockAccessToken(); 445 capturedBody = body; 446 return { 447 data: { 448 id: 'REFUND_WITH_REASON', 449 status: 'COMPLETED', 450 amount: { value: '159.00', currency_code: 'GBP' }, 451 }, 452 }; 453 }); 454 455 await refundPayment('CAP_WITH_REASON', 'Customer requested via email'); 456 457 assert.deepEqual(capturedBody, { note_to_payer: 'Customer requested via email' }); 458 }); 459 460 test('handles refund response with missing amount field', async () => { 461 axiosPostMock.mock.mockImplementation(async url => { 462 if (url.includes('/oauth2/token')) return mockAccessToken(); 463 return { 464 data: { 465 id: 'REFUND_NO_AMOUNT', 466 status: 'COMPLETED', 467 // No amount field 468 }, 469 }; 470 }); 471 472 const result = await refundPayment('CAP_NO_AMOUNT', 'test'); 473 474 assert.equal(result.success, true); 475 assert.equal(result.refund_id, 'REFUND_NO_AMOUNT'); 476 assert.equal(result.amount, 0, 'Should default to 0 when amount is missing'); 477 assert.equal(result.currency, undefined, 'Currency should be undefined when no amount'); 478 }); 479 480 test('uses correct sandbox API URL for refund endpoint', async () => { 481 let capturedUrl = null; 482 axiosPostMock.mock.mockImplementation(async (url) => { 483 if (url.includes('/oauth2/token')) return mockAccessToken(); 484 capturedUrl = url; 485 return { 486 data: { 487 id: 'REFUND_URL_TEST', 488 status: 'COMPLETED', 489 amount: { value: '100.00', currency_code: 'USD' }, 490 }, 491 }; 492 }); 493 494 await refundPayment('CAP_URL_TEST', 'test'); 495 496 assert.ok(capturedUrl.includes('sandbox.paypal.com'), `URL was: ${capturedUrl}`); 497 assert.ok(capturedUrl.includes('/v2/payments/captures/CAP_URL_TEST/refund')); 498 }); 499 500 test('wraps PayPal refund error with descriptive message', async () => { 501 axiosPostMock.mock.mockImplementation(async url => { 502 if (url.includes('/oauth2/token')) return mockAccessToken(); 503 const err = new Error('Request failed'); 504 err.response = { 505 data: { message: 'CAPTURE_FULLY_REFUNDED' }, 506 }; 507 throw err; 508 }); 509 510 await assert.rejects( 511 () => refundPayment('CAP_ALREADY_REFUNDED'), 512 err => { 513 assert.ok(err.message.includes('PayPal refund failed')); 514 assert.ok(err.message.includes('CAPTURE_FULLY_REFUNDED')); 515 return true; 516 } 517 ); 518 }); 519 }); 520 521 // ---- generatePaymentMessage additional coverage ---- 522 523 describe('generatePaymentMessage() — additional coverage', () => { 524 test('SMS message includes Audit & Fix branding', async () => { 525 const msg = await generatePaymentMessage('https://paypal.com/pay', 'sms', 'mybiz.com', 'US'); 526 assert.ok(msg.includes('Marcus from Audit & Fix')); 527 assert.ok(msg.includes('mybiz.com')); 528 }); 529 530 test('email message includes Marcus Webb signature', async () => { 531 const msg = await generatePaymentMessage('https://paypal.com/pay', 'email', 'mybiz.com', 'US'); 532 assert.ok(msg.includes('Marcus Webb')); 533 assert.ok(msg.includes('Audit & Fix')); 534 assert.ok(msg.includes('auditandfix.com')); 535 }); 536 537 test('email message includes payment link on its own line', async () => { 538 const link = 'https://paypal.com/pay?token=UNIQUE_TOKEN'; 539 const msg = await generatePaymentMessage(link, 'email', 'client.com', 'GB'); 540 // Link should appear in the message 541 assert.ok(msg.includes(link)); 542 // Price should use GBP 543 assert.ok(msg.includes('\u00a3159.00 GBP')); 544 }); 545 546 test('SMS message for GB uses GBP pricing', async () => { 547 const msg = await generatePaymentMessage('https://link', 'sms', 'uk-biz.co.uk', 'GB'); 548 assert.ok(msg.includes('\u00a3159.00 GBP')); 549 }); 550 551 test('defaults to US pricing when countryCode is not provided', async () => { 552 // The function uses countryCode || 'US' as fallback 553 const msg = await generatePaymentMessage('https://link', 'sms', 'biz.com', undefined); 554 assert.ok(msg.includes('$297.00 USD')); 555 }); 556 557 test('email message describes the product clearly (no CRO jargon)', async () => { 558 const msg = await generatePaymentMessage('https://link', 'email', 'test.com', 'US'); 559 // Should mention "Homepage Conversion Audit" not raw "CRO" 560 assert.ok(msg.includes('Homepage Conversion Audit')); 561 // Should mention it's one-time 562 assert.ok(msg.includes('one-time')); 563 }); 564 }); 565 566 // ---- createPaymentOrder URL and structure checks ---- 567 568 describe('createPaymentOrder() — order structure', () => { 569 test('sets return_url and cancel_url from BASE_URL env', async () => { 570 axiosPostMock.mock.mockImplementation(async url => { 571 if (url.includes('/oauth2/token')) return mockAccessToken(); 572 return mockOrderResponse('ORDER_URLS'); 573 }); 574 575 await createPaymentOrder({ 576 domain: 'url-test.com', 577 email: 'buyer@test.com', 578 siteId: 1, 579 conversationId: 1, 580 countryCode: 'US', 581 }); 582 583 const orderCall = axiosPostMock.mock.calls.find(c => 584 c.arguments[0].includes('/v2/checkout/orders') 585 ); 586 const body = orderCall.arguments[1]; 587 const context = body.payment_source.paypal.experience_context; 588 589 assert.equal(context.return_url, 'https://test.example.com/payment/success'); 590 assert.equal(context.cancel_url, 'https://test.example.com/payment/cancel'); 591 assert.equal(context.brand_name, 'Audit&Fix'); 592 assert.equal(context.user_action, 'PAY_NOW'); 593 }); 594 595 test('sets item category to DIGITAL_GOODS', async () => { 596 axiosPostMock.mock.mockImplementation(async url => { 597 if (url.includes('/oauth2/token')) return mockAccessToken(); 598 return mockOrderResponse('ORDER_DIGITAL'); 599 }); 600 601 await createPaymentOrder({ 602 domain: 'digital.com', 603 email: 'buyer@test.com', 604 siteId: 1, 605 conversationId: 1, 606 countryCode: 'US', 607 }); 608 609 const orderCall = axiosPostMock.mock.calls.find(c => 610 c.arguments[0].includes('/v2/checkout/orders') 611 ); 612 const body = orderCall.arguments[1]; 613 assert.equal(body.purchase_units[0].items[0].category, 'DIGITAL_GOODS'); 614 assert.equal(body.purchase_units[0].items[0].quantity, '1'); 615 }); 616 617 test('sets intent to CAPTURE', async () => { 618 axiosPostMock.mock.mockImplementation(async url => { 619 if (url.includes('/oauth2/token')) return mockAccessToken(); 620 return mockOrderResponse('ORDER_INTENT'); 621 }); 622 623 await createPaymentOrder({ 624 domain: 'intent.com', 625 email: 'buyer@test.com', 626 siteId: 1, 627 conversationId: 1, 628 countryCode: 'US', 629 }); 630 631 const orderCall = axiosPostMock.mock.calls.find(c => 632 c.arguments[0].includes('/v2/checkout/orders') 633 ); 634 const body = orderCall.arguments[1]; 635 assert.equal(body.intent, 'CAPTURE'); 636 }); 637 638 test('includes domain in item description', async () => { 639 axiosPostMock.mock.mockImplementation(async url => { 640 if (url.includes('/oauth2/token')) return mockAccessToken(); 641 return mockOrderResponse('ORDER_DESC'); 642 }); 643 644 await createPaymentOrder({ 645 domain: 'my-special-domain.com', 646 email: 'buyer@test.com', 647 siteId: 1, 648 conversationId: 1, 649 countryCode: 'US', 650 }); 651 652 const orderCall = axiosPostMock.mock.calls.find(c => 653 c.arguments[0].includes('/v2/checkout/orders') 654 ); 655 const body = orderCall.arguments[1]; 656 assert.ok(body.purchase_units[0].description.includes('my-special-domain.com')); 657 assert.ok(body.purchase_units[0].items[0].description.includes('my-special-domain.com')); 658 }); 659 660 test('returns exchangeRate from pricing in result', async () => { 661 axiosPostMock.mock.mockImplementation(async url => { 662 if (url.includes('/oauth2/token')) return mockAccessToken(); 663 return mockOrderResponse('ORDER_RATE'); 664 }); 665 666 const result = await createPaymentOrder({ 667 domain: 'rate-test.com', 668 email: 'buyer@test.com', 669 siteId: 1, 670 conversationId: 1, 671 countryCode: 'AU', 672 }); 673 674 assert.equal(result.exchangeRate, 1.505); 675 assert.equal(result.currency, 'AUD'); 676 assert.equal(result.amountUsd, 297); 677 }); 678 679 test('amount breakdown matches item total', async () => { 680 axiosPostMock.mock.mockImplementation(async url => { 681 if (url.includes('/oauth2/token')) return mockAccessToken(); 682 return mockOrderResponse('ORDER_BREAKDOWN'); 683 }); 684 685 await createPaymentOrder({ 686 domain: 'breakdown.com', 687 email: 'buyer@test.com', 688 siteId: 1, 689 conversationId: 1, 690 countryCode: 'GB', 691 }); 692 693 const orderCall = axiosPostMock.mock.calls.find(c => 694 c.arguments[0].includes('/v2/checkout/orders') 695 ); 696 const body = orderCall.arguments[1]; 697 const pu = body.purchase_units[0]; 698 699 // Top-level amount, breakdown item_total, and item unit_amount should all match 700 assert.equal(pu.amount.value, pu.amount.breakdown.item_total.value); 701 assert.equal(pu.amount.value, pu.items[0].unit_amount.value); 702 assert.equal(pu.amount.currency_code, pu.amount.breakdown.item_total.currency_code); 703 assert.equal(pu.amount.currency_code, pu.items[0].unit_amount.currency_code); 704 }); 705 }); 706 });