diff --git a/billing-server/server.js b/billing-server/server.js index 002a71c..8e31661 100644 --- a/billing-server/server.js +++ b/billing-server/server.js @@ -160,7 +160,7 @@ app.post('/v1/checkout/sessions', (req, res) => { status: 'open', customer: customerId, payment_intent: paymentIntentId, - url: `http://localhost:${PORT}/stripe-checkout?session_id=${sessionId}`, + url: `http://localhost:${PORT}/stripe/checkin`, line_items: lineItems, } stripeSessions.set(sessionId, session) diff --git a/charging-docker/docker-compose.yml b/charging-docker/docker-compose.yml index 815f1e6..8b450d1 100644 --- a/charging-docker/docker-compose.yml +++ b/charging-docker/docker-compose.yml @@ -33,6 +33,7 @@ services: # Service Configuration - BAE_SERVICE_HOST=http://proxy.docker:8004/ - BAE_CB_LOCAL_SITE=http://charging.docker:8006/ + - GUNICORN_CMD_ARGS=--timeout 120 # TMForum APIs - all-in-one (port 8633) - BAE_CB_CATALOG=http://host.docker.internal:8633/tmf-api/productCatalogManagement/v4 @@ -58,7 +59,10 @@ services: - BAE_LP_OAUTH2_CUSTOMER_ROLE=customer - BAE_LP_OAUTH2_ORG_ADMIN_ROLE=orgAdmin # Payment Configuration - - BAE_CB_PAYMENT_METHOD=stripe + - BAE_CB_PAYMENT_METHOD=redsys + - BAE_CB_REDSYS_MERCHANT_CODE=263100000 + - BAE_CB_REDSYS_TERMINAL=005 + - BAE_CB_REDSYS_SECRET_KEY=sq7HjrUOBfKmC576ILgskD5srU870gJ7 - BAE_CB_BILLING_ENGINE=local - BAE_CB_DPAS_CLIENT_API_URL=http://host.docker.internal:4201/api/payment-start - BAE_SERVICE_HOST=http://localhost:4200/ @@ -70,4 +74,3 @@ services: networks: main: external: true - diff --git a/cypress.config.ts b/cypress.config.ts index 592f0df..6b8bf9e 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -13,7 +13,9 @@ export default defineConfig({ requestTimeout: 30000, responseTimeout: 60000, env: { - PAYMENT_METHOD: process.env.BAE_CB_PAYMENT_METHOD || 'stripe' + PAYMENT_METHOD: process.env.BAE_CB_PAYMENT_METHOD || 'redsys', + REDSYS_ORIGIN: process.env.BAE_CB_REDSYS_ORIGIN || 'https://sis-t.redsys.es:25443', + REDSYS_AUTH_ORIGIN: process.env.BAE_CB_REDSYS_AUTH_ORIGIN || 'https://sis-d.redsys.es' } } }) diff --git a/cypress/e2e/01-happy-journey.cy.ts b/cypress/e2e/01-happy-journey.cy.ts index 46d1df3..65d6f3d 100644 --- a/cypress/e2e/01-happy-journey.cy.ts +++ b/cypress/e2e/01-happy-journey.cy.ts @@ -166,12 +166,16 @@ describe('Happy Journey E2E', { cy.wait('@saveBilling') cy.wait('@getBilling') + // Wait for the billing address to be processed and selected + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') cy.completePayment() cy.wait('@checkin') + cy.waitForOrdersAfterPayment() cy.wait('@getBilling') // ============================================ diff --git a/cypress/e2e/04-billing-and-payment-scheduler.cy.ts b/cypress/e2e/04-billing-and-payment-scheduler.cy.ts index b6e0608..a66b3c3 100644 --- a/cypress/e2e/04-billing-and-payment-scheduler.cy.ts +++ b/cypress/e2e/04-billing-and-payment-scheduler.cy.ts @@ -17,13 +17,15 @@ import { * * After each billing scheduler run, also triggers the payment scheduler * cron job and verifies that the resulting CustomerBill ends up 'settled' - * once the recurring charge against the stored Stripe payment method succeeds. + * once the recurring charge against the stored payment method succeeds. * * Requires BAE_CB_BILLING_HTTP_ENABLED=true in the charging container. */ const CHARGING_URL = 'http://localhost:8006' const TMF_URL = 'http://localhost:8633' +const SCORPIO_URL = 'http://localhost:1026' const BILLING_SERVER_URL = 'http://localhost:4201' +const IS_REDSYS = Cypress.env('PAYMENT_METHOD') === 'redsys' const runPaymentScheduler = () => { cy.request({ url: `${CHARGING_URL}/charging/api/test/paymentScheduler`, method: 'POST' }).then((res) => { @@ -41,6 +43,65 @@ const expectCustomerBillState = (billId: string, expectedState: string) => { }) } +const patchCustomerBillAmountInScorpio = ( + billId: string, + taxIncludedAmount: { unit: string, value: number | string } +) => { + const value = Number(taxIncludedAmount.value) + + cy.request({ + url: `${SCORPIO_URL}/ngsi-ld/v1/entities/${encodeURIComponent(billId)}/attrs`, + method: 'PATCH', + headers: { 'Content-Type': 'application/ld+json' }, + body: { + taxIncludedAmount: { + type: 'Property', + tmfValue: { type: 'Property', value }, + unit: { type: 'Property', value: taxIncludedAmount.unit }, + value: { tmfValue: value, unit: taxIncludedAmount.unit }, + }, + '@context': ['https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.7.jsonld'], + }, + }).then((res) => { + expect(res.status).to.eq(204) + }) +} + +const expectRecurringPaymentToRetry = ( + billId: string, + stripeStatus: 'processing' | 'requires_payment_method' +) => { + const runAndVerifyRetry = (restorePayment: () => void = () => {}) => { + runPaymentScheduler() + expectCustomerBillState(billId, 'new') + + restorePayment() + runPaymentScheduler() + expectCustomerBillState(billId, 'settled') + } + + if (!IS_REDSYS) { + cy.request(`${BILLING_SERVER_URL}/stripe/set-recurring-status/${stripeStatus}`) + runAndVerifyRetry() + return + } + + cy.request({ + url: `${TMF_URL}/tmf-api/customerBillManagement/v4/customerBill/${billId}`, + method: 'GET', + }).then((res) => { + expect(res.status).to.eq(200) + const originalAmount = res.body.taxIncludedAmount + const rejectedAmount = { + ...originalAmount, + value: `${Math.floor(Number(originalAmount.value))}.96`, + } + + patchCustomerBillAmountInScorpio(billId, rejectedAmount) + runAndVerifyRetry(() => patchCustomerBillAmountInScorpio(billId, originalAmount)) + }) +} + describe('Billing Scheduler Period Coverage', { viewportHeight: 1080, viewportWidth: 1920, @@ -66,6 +127,7 @@ describe('Billing Scheduler Period Coverage', { cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') cy.intercept('GET', '**/shoppingCart/item/').as('cartItem') + cy.intercept('GET', '**/paymentInfo').as('getPaymentInfo') // ============================================ // Verify catalog and product spec exist (from happy journey) @@ -140,7 +202,12 @@ describe('Billing Scheduler Period Coverage', { cy.getBySel('savePricePlan').click() cy.getBySel('offerNext').click() - cy.getBySel('procurement').select('automatic') + cy.wait('@getPaymentInfo') + .its('response.body.gatewaysCount') + .should('be.greaterThan', 0) + cy.getBySel('procurement') + .select('automatic') + .should('have.value', 'automatic') cy.getBySel('offerNext').click() waitForInitialPaginatedList('**/catalog/productOffering?*', () => { @@ -189,16 +256,19 @@ describe('Billing Scheduler Period Coverage', { cy.getBySel('cartPurchase').click() cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() // ============================================ // Step 4: Complete payment (activation) // ============================================ cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkin') + cy.waitForOrdersAfterPayment() cy.getBySel('ordersTable').should('be.visible') cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -409,18 +479,12 @@ describe('Billing Scheduler Period Coverage', { expect(new Date(acbrs[0].periodCoverage.endDateTime).getTime()).to.equal(week2End.getTime()) // ============================================ - // Payment scheduler: recurring charge comes back pending on the - // first attempt, so the CB must be left untouched ('new'), then - // settles on the next scheduler run once the charge resolves. + // Stripe leaves the first charge pending; Redsys uses a sandbox + // rejection amount. Both must leave the bill 'new' and then settle. // ============================================ const cbId = acbrs[0].bill.id - cy.request(`${BILLING_SERVER_URL}/stripe/set-recurring-status/processing`) - runPaymentScheduler() - expectCustomerBillState(cbId, 'new') - - runPaymentScheduler() - expectCustomerBillState(cbId, 'settled') + expectRecurringPaymentToRetry(cbId, 'processing') }) // — Week 3 — @@ -465,18 +529,12 @@ describe('Billing Scheduler Period Coverage', { expect(new Date(acbrs[0].periodCoverage.endDateTime).getTime()).to.equal(week4End.getTime()) // ============================================ - // Payment scheduler: recurring charge fails on the first attempt, - // so the CB must be left untouched ('new'), then settles on the - // next scheduler run once the charge succeeds. + // Stripe requires a new payment method; Redsys uses a sandbox + // rejection amount. Both must leave the bill 'new' and then settle. // ============================================ const cbId = acbrs[0].bill.id - cy.request(`${BILLING_SERVER_URL}/stripe/set-recurring-status/requires_payment_method`) - runPaymentScheduler() - expectCustomerBillState(cbId, 'new') - - runPaymentScheduler() - expectCustomerBillState(cbId, 'settled') + expectRecurringPaymentToRetry(cbId, 'requires_payment_method') }) // ============================================ diff --git a/cypress/e2e/edges/global-states/test-2.cy.ts b/cypress/e2e/edges/global-states/test-2.cy.ts index a22f7e5..7a8b8cb 100644 --- a/cypress/e2e/edges/global-states/test-2.cy.ts +++ b/cypress/e2e/edges/global-states/test-2.cy.ts @@ -45,7 +45,11 @@ describe('Check order global states - Reverse test (auto and semi failed, iterat cy.intercept('POST', '**/shoppingCart/item/').as('postCart') cy.intercept('POST', '**/ordering/productOrder').as('createOrder') cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') + cy.intercept('GET', '**/ordering/productOrder?*relatedParty.role=seller*').as('getProviderOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') + cy.intercept('GET', '**/catalog/productOffering?*', (request) => { + delete request.headers['if-none-match'] + }) cy.intercept('PATCH', '**/ordering/productOrder/**').as('patchOrder') cy.loginAsAdmin() @@ -103,13 +107,14 @@ describe('Check order global states - Reverse test (auto and semi failed, iterat cy.getBySel('shoppingCart').click() cy.getBySel('cartPurchase').click() - cy.intercept('POST', '**/ordering/productOrder').as('createOrder') + cy.deferPaymentRedirect() cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') cy.wait('@getBilling') cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') + cy.waitForOrdersBeforePayment() // Fail the auto and semi offering cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') @@ -120,8 +125,9 @@ describe('Check order global states - Reverse test (auto and semi failed, iterat // Navigate to product orders as provider cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // Find the most recent order and set auto and semi to failed diff --git a/cypress/e2e/edges/global-states/test1.cy.ts b/cypress/e2e/edges/global-states/test1.cy.ts index 2cfe499..0b6027f 100644 --- a/cypress/e2e/edges/global-states/test1.cy.ts +++ b/cypress/e2e/edges/global-states/test1.cy.ts @@ -58,8 +58,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -119,8 +120,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { diff --git a/cypress/e2e/edges/global-states/test2.cy.ts b/cypress/e2e/edges/global-states/test2.cy.ts index dcf1f15..c44563b 100644 --- a/cypress/e2e/edges/global-states/test2.cy.ts +++ b/cypress/e2e/edges/global-states/test2.cy.ts @@ -58,8 +58,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -119,8 +120,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { diff --git a/cypress/e2e/edges/global-states/test3.cy.ts b/cypress/e2e/edges/global-states/test3.cy.ts index e591f89..c8a6af2 100644 --- a/cypress/e2e/edges/global-states/test3.cy.ts +++ b/cypress/e2e/edges/global-states/test3.cy.ts @@ -58,8 +58,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -120,8 +121,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state diff --git a/cypress/e2e/edges/global-states/test4.cy.ts b/cypress/e2e/edges/global-states/test4.cy.ts index ed6b1ed..9df4959 100644 --- a/cypress/e2e/edges/global-states/test4.cy.ts +++ b/cypress/e2e/edges/global-states/test4.cy.ts @@ -58,8 +58,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -121,8 +122,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { diff --git a/cypress/e2e/edges/global-states/test5.cy.ts b/cypress/e2e/edges/global-states/test5.cy.ts index c0258e0..8da4362 100644 --- a/cypress/e2e/edges/global-states/test5.cy.ts +++ b/cypress/e2e/edges/global-states/test5.cy.ts @@ -58,9 +58,10 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') - cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') cy.getBySel('ordersTable').should('be.visible') + cy.getBySel('asProviderTab').should('be.visible').click() + cy.wait('@getProviderOrders') + cy.getBySel('ordersTable', { timeout: 200000 }).should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { cy.contains(/inprogress/i) @@ -121,9 +122,10 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') - cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') cy.getBySel('ordersTable').should('be.visible') + cy.getBySel('asProviderTab').should('be.visible').click() + cy.wait('@getProviderOrders') + cy.getBySel('ordersTable', { timeout: 200000 }).should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { cy.contains(/partial/i) diff --git a/cypress/e2e/edges/global-states/test6.cy.ts b/cypress/e2e/edges/global-states/test6.cy.ts index 63fd56e..798f784 100644 --- a/cypress/e2e/edges/global-states/test6.cy.ts +++ b/cypress/e2e/edges/global-states/test6.cy.ts @@ -58,8 +58,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { @@ -121,8 +122,9 @@ describe('Check order global states', { cy.changeSessionTo('SELLER ORG') cy.visit('/product-orders') cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') cy.getBySel('asProviderTab').should('be.visible').click() - cy.wait('@getOrders') + cy.wait('@getProviderOrders') cy.getBySel('ordersTable').should('be.visible') // check global state cy.getBySel('ordersTable').find('tbody tr').first().within(() => { diff --git a/cypress/e2e/edges/mod-order.cy.ts b/cypress/e2e/edges/mod-order.cy.ts index 799d8a4..3c7e116 100644 --- a/cypress/e2e/edges/mod-order.cy.ts +++ b/cypress/e2e/edges/mod-order.cy.ts @@ -177,6 +177,7 @@ describe('Product Modification Order E2E', { cy.wait('@getBilling') cy.get('body').then($body => { if ($body.find('[data-cy="billingTitle"]').length > 0) { + cy.intercept('POST', '**/account/billingAccount').as('saveBilling') createCheckoutBilling({ title: 'ModOrder Billing', country: 'ES', @@ -187,18 +188,22 @@ describe('Product Modification Order E2E', { email: 'buyer@test.com', phoneNumber: '600123456' }) - cy.intercept('POST', '**/account/billingAccount').as('saveBilling') + cy.wait('@saveBilling') + cy.wait('@getBilling') } }) + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() // Confirm payment cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkin') + cy.waitForOrdersAfterPayment() // ============================================ // Verify initial purchase: order completed + invoice @@ -267,6 +272,7 @@ describe('Product Modification Order E2E', { cy.get('.backdrop-blur-sm').should('be.visible').within(() => { cy.get('app-billing-address').first().click() // Click confirm modify button + cy.deferPaymentRedirect() cy.get('button').contains(/confirm|Confirm/i).click() }) cy.wait('@createOrder') @@ -275,7 +281,7 @@ describe('Product Modification Order E2E', { // Verify modification: checkin + order + invoice // ============================================ cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkinModify') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkinModify') cy.visit('/product-orders') diff --git a/cypress/e2e/edges/multi-price-billing.cy.ts b/cypress/e2e/edges/multi-price-billing.cy.ts index 2f0ab76..6d024d6 100644 --- a/cypress/e2e/edges/multi-price-billing.cy.ts +++ b/cypress/e2e/edges/multi-price-billing.cy.ts @@ -3,6 +3,7 @@ import { updateOffering, clickLoadMoreUntilGone, createOffering, + selectProcurementMode, waitForInitialPaginatedList, } from '../../support/form-helpers' @@ -44,6 +45,7 @@ describe('Multi-Price Component Billing Edge Cases', { cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') cy.intercept('GET', '**/shoppingCart/item/').as('cartItem') + cy.intercept('GET', '**/paymentInfo').as('getPaymentInfo') // ============================================ // Verify that catalog and product spec exist (from happy journey test) @@ -67,7 +69,9 @@ describe('Multi-Price Component Billing Edge Cases', { // Step 1.1: Basic Info cy.getBySel('offerName').should('be.visible').type(offeringName) cy.getBySel('textArea').type('Offering with recurring and recurring-prepaid components') - cy.getBySel('offerNext').click() + waitForInitialPaginatedList('**/catalog/productSpecification?*', () => { + cy.getBySel('offerNext').click() + }) // Step 1.2: Select Product Spec cy.getBySel('prodSpecs').contains(productSpecName).click() @@ -113,7 +117,7 @@ describe('Multi-Price Component Billing Edge Cases', { cy.getBySel('offerNext').click() // Step 1.7: Procurement - Set to automatic - cy.getBySel('procurement').select('automatic') + selectProcurementMode('automatic') cy.getBySel('offerNext').click() // Step 1.8: Finish @@ -170,14 +174,17 @@ describe('Multi-Price Component Billing Edge Cases', { // Step 5: Wait for billing address and checkout // ============================================ cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() // Complete payment simulation cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkin') + cy.waitForOrdersAfterPayment() // ============================================ // Step 6: Verify Customer Bill and ACBRs @@ -212,6 +219,7 @@ describe('Multi-Price Component Billing Edge Cases', { cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') cy.intercept('GET', '**/shoppingCart/item/').as('cartItem') + cy.intercept('GET', '**/paymentInfo').as('getPaymentInfo') // Verify catalog and product spec exist cy.visit('/my-offerings') @@ -230,7 +238,9 @@ describe('Multi-Price Component Billing Edge Cases', { cy.getBySel('offerName').should('be.visible').type(offeringName) cy.getBySel('textArea').type('Offering with one-time and recurring-prepaid') - cy.getBySel('offerNext').click() + waitForInitialPaginatedList('**/catalog/productSpecification?*', () => { + cy.getBySel('offerNext').click() + }) cy.getBySel('prodSpecs').contains(productSpecName).click() cy.getBySel('offerNext').click() @@ -270,7 +280,7 @@ describe('Multi-Price Component Billing Edge Cases', { cy.getBySel('savePricePlan').click() cy.getBySel('offerNext').click() - cy.getBySel('procurement').select('automatic') + selectProcurementMode('automatic') cy.getBySel('offerNext').click() waitForInitialPaginatedList('**/catalog/productOffering?*', () => { @@ -311,13 +321,16 @@ describe('Multi-Price Component Billing Edge Cases', { cy.getBySel('cartPurchase').click() cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkin') + cy.waitForOrdersAfterPayment() cy.getBySel('ordersTable').should('be.visible') cy.getBySel('ordersTable').contains('completed') @@ -417,13 +430,16 @@ describe('Multi-Price Component Billing Edge Cases', { cy.getBySel('cartPurchase').click() cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') - cy.completePayment() + cy.completePayment({ recurring: true }) cy.wait('@checkin') + cy.waitForOrdersAfterPayment() cy.getBySel('ordersTable').should('be.visible') cy.getBySel('ordersTable').contains('completed') diff --git a/cypress/e2e/edges/procurement.cy.ts b/cypress/e2e/edges/procurement.cy.ts index 952399c..6b36726 100644 --- a/cypress/e2e/edges/procurement.cy.ts +++ b/cypress/e2e/edges/procurement.cy.ts @@ -319,9 +319,11 @@ describe('Payment Automatic with Manual Procurement E2E', { // Step 5: Complete checkout (automatic payment) // ============================================ cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() cy.wait('@createOrder') - cy.wait('@getOrders') + cy.waitForOrdersBeforePayment() // ============================================ // Step 6: Verify order is in inProgress state (payment done, waiting for manual procurement) @@ -329,6 +331,7 @@ describe('Payment Automatic with Manual Procurement E2E', { cy.intercept('**/charging/api/orderManagement/orders/confirm/').as('checkin') cy.completePayment() cy.wait('@checkin') + cy.waitForOrdersAfterPayment() cy.getBySel('ordersTable').should('be.visible') // For manual procurement, get the most recent order (first row in tbody) diff --git a/cypress/e2e/edges/transfer-bank.cy.ts b/cypress/e2e/edges/transfer-bank.cy.ts index 24e03b7..1b49300 100644 --- a/cypress/e2e/edges/transfer-bank.cy.ts +++ b/cypress/e2e/edges/transfer-bank.cy.ts @@ -11,8 +11,12 @@ import { * Creates an offering, buys it as buyer, intercepts the order id, * then settles the customer bill directly via API (bypassing billing-server) * and verifies the order completes and product appears in inventory. + * + * This scenario is specific to the DPAS pending-payment flow. */ -describe('Manual Bill Settle Edge Case', { +const describeForDpas = Cypress.env('PAYMENT_METHOD') === 'dpas' ? describe : describe.skip + +describeForDpas('Manual Bill Settle Edge Case', { viewportHeight: 1080, viewportWidth: 1920, }, () => { @@ -154,6 +158,8 @@ describe('Manual Bill Settle Edge Case', { cy.getBySel('cartPurchase').click() cy.wait('@getBilling') + cy.wait(2000) + cy.deferPaymentRedirect() cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() // Intercept order creation to capture the order id diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index aa35e06..21a7896 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -59,30 +59,141 @@ Cypress.Commands.add('closeFeedbackModalIfVisible', () => { }) const BILLING_SERVER_URL = 'http://localhost:4201' +const PAYMENT_METHOD = Cypress.env('PAYMENT_METHOD') +const IS_REDSYS = PAYMENT_METHOD === 'redsys' +const REDSYS_ORIGIN = Cypress.env('REDSYS_ORIGIN') +const REDSYS_AUTH_ORIGIN = Cypress.env('REDSYS_AUTH_ORIGIN') +let deferredPaymentUrl: string | undefined // Payment gateway endpoints are namespaced under /stripe for the Stripe client; -// the DPAS client uses the unprefixed endpoints. -const PAYMENT_PREFIX = Cypress.env('PAYMENT_METHOD') === 'dpas' ? '' : '/stripe' +// the DPAS and Redsys clients use the unprefixed endpoints. +const PAYMENT_PREFIX = ['dpas', 'redsys'].includes(PAYMENT_METHOD) ? '' : '/stripe' // Reset the mock billing-server's payment gateway state (Stripe by default) Cypress.Commands.add('clearBilling', () => { + if (IS_REDSYS) { + return + } + cy.request({ url: `${BILLING_SERVER_URL}${PAYMENT_PREFIX}/clear`, method: 'POST' }).then((response) => { expect(response.status).to.eq(200) }) }) +// Keep the browser on the frontend while a test inspects the unpaid order. +// The redirect is resumed by completePayment() or cancelPayment(). +Cypress.Commands.add('deferPaymentRedirect', () => { + deferredPaymentUrl = undefined + + cy.intercept('POST', '**/ordering/productOrder', (request) => { + request.continue((response) => { + const redirectHeader = Object.keys(response.headers).find( + (header) => header.toLowerCase() === 'x-redirect-url' + ) + const redirectUrl = redirectHeader + ? response.headers[redirectHeader] + : undefined + + if (!redirectHeader || typeof redirectUrl !== 'string' || !redirectUrl) { + throw new Error('Payment redirect was not returned by charging') + } + + deferredPaymentUrl = redirectUrl + delete response.headers[redirectHeader] + }) + }).as('createOrder') +}) + +const takeDeferredPaymentUrl = () => { + const paymentUrl = deferredPaymentUrl + deferredPaymentUrl = undefined + return paymentUrl +} + +// The mock gateways redirect through /checkin before the orders page is loaded. +// Redsys must be completed first because its external payment form is already open. +Cypress.Commands.add('waitForOrdersBeforePayment', () => { + if (!IS_REDSYS) { + cy.wait('@getOrders') + } +}) + +Cypress.Commands.add('waitForOrdersAfterPayment', () => { + if (IS_REDSYS) { + cy.wait('@getOrders') + } +}) + // Complete the pending checkout, redirecting back to the order's success url -Cypress.Commands.add('completePayment', () => { - cy.visit(`${BILLING_SERVER_URL}${PAYMENT_PREFIX}/checkin`) +Cypress.Commands.add('completePayment', (options: { recurring?: boolean } = {}) => { + const paymentUrl = takeDeferredPaymentUrl() + + if (IS_REDSYS) { + if (options.recurring) { + cy.origin(REDSYS_AUTH_ORIGIN, () => { + Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('$ is not defined')) { + return false + } + }) + }) + } + + if (paymentUrl) { + cy.visit(paymentUrl) + } + + cy.origin(REDSYS_ORIGIN, () => { + cy.get('#card-number').should('be.visible').type('4548814479727229') + cy.get('#card-expiration').type('1249') + cy.get('#card-cvv').type('123') + cy.get('body').then(($body) => { + if ($body.find('#cardholder-name').length) { + cy.get('#cardholder-name').type('E2E TEST') + } + }) + cy.get('#divImgAceptar').should('be.visible').click() + }) + + if (options.recurring) { + cy.origin(REDSYS_AUTH_ORIGIN, () => { + cy.get('input[name="option"][value="1"]').should('be.visible').check() + cy.get('#boton').should('be.visible').click() + }) + } + + return cy.origin(REDSYS_ORIGIN, () => { + cy.get('input[type="button"][lngid="continuar"]').should('be.visible').click() + }) + } + + cy.visit(paymentUrl || `${BILLING_SERVER_URL}${PAYMENT_PREFIX}/checkin`) }) // Cancel the pending checkout, redirecting back to the order's cancel url Cypress.Commands.add('cancelPayment', () => { + const paymentUrl = takeDeferredPaymentUrl() + + if (IS_REDSYS) { + if (paymentUrl) { + cy.visit(paymentUrl) + } + + return cy.origin(REDSYS_ORIGIN, () => { + cy.get('#divImgCancelar').should('be.visible').click() + cy.get('input[type="button"][lngid="continuar"]').should('be.visible').click() + }) + } + cy.visit(`${BILLING_SERVER_URL}${PAYMENT_PREFIX}/bad-checkin`) }) // Mark the next checkout as left pending instead of completed Cypress.Commands.add('setPaymentPending', () => { + if (IS_REDSYS) { + return + } + cy.request({ url: `${BILLING_SERVER_URL}${PAYMENT_PREFIX}/set-pending`, method: 'GET' }).then((response) => { expect(response.status).to.eq(200) }) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 3515f17..3ac716e 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,6 +1,12 @@ // Import commands import './commands' +afterEach(() => { + if (Cypress.env('PAYMENT_METHOD') === 'redsys') { + cy.visit('/') + } +}) + // Extend Cypress namespace with custom commands declare global { namespace Cypress { @@ -38,11 +44,31 @@ declare global { */ clearBilling(): Chainable + /** + * Save the next payment redirect so the test can complete or cancel it later. + * @example cy.deferPaymentRedirect() + */ + deferPaymentRedirect(): Chainable + + /** + * Wait for orders before payment when using a mock payment gateway. + * Redsys skips this wait so Cypress can complete its external form first. + * @example cy.waitForOrdersBeforePayment() + */ + waitForOrdersBeforePayment(): Chainable + + /** + * Wait for orders after payment when using Redsys. + * @example cy.waitForOrdersAfterPayment() + */ + waitForOrdersAfterPayment(): Chainable + /** * Complete the pending checkout, redirecting back to the order's success url - * @example cy.completePayment() + * @param options - Set recurring for Redsys payments that require 3DS authentication + * @example cy.completePayment({ recurring: true }) */ - completePayment(): Chainable + completePayment(options?: { recurring?: boolean }): Chainable /** * Cancel the pending checkout, redirecting back to the order's cancel url diff --git a/cypress/support/form-helpers.ts b/cypress/support/form-helpers.ts index 25d5217..86be077 100644 --- a/cypress/support/form-helpers.ts +++ b/cypress/support/form-helpers.ts @@ -59,6 +59,13 @@ export interface UpdateOfferingParams { status: string } +export function selectProcurementMode(procurement: string): void { + cy.wait('@getPaymentInfo') + cy.getBySel('procurement') + .select(procurement) + .should('have.value', procurement) +} + export interface BillingParams { title: string country: string @@ -326,6 +333,7 @@ export function createOffering({ procurement }: OfferingParams): void { cy.intercept('GET', '**/usage/usageSpecification?*').as('usageGET') + cy.intercept('GET', '**/paymentInfo').as('getPaymentInfo') cy.visit('/my-offerings') cy.getBySel('offerSection').click() cy.getBySel('newOffering').click() @@ -392,7 +400,7 @@ export function createOffering({ cy.getBySel('offerNext').click() // Step 7: procurement info - cy.getBySel('procurement').select(procurement) + selectProcurementMode(procurement) cy.getBySel('offerNext').click() // Step 8: Finish @@ -443,6 +451,7 @@ export function updateOffering({ name, status }: UpdateOfferingParams): void { function setupRequestTracker(apiPattern: string | string[], idleMs = 300) { let pendingRequests = 0 let lastActivity = Date.now() + let seenRequest = false let requestId = 0 const patterns = Array.isArray(apiPattern) ? apiPattern : [apiPattern] @@ -452,6 +461,7 @@ function setupRequestTracker(apiPattern: string | string[], idleMs = 300) { cy.intercept('GET', pattern, (req) => { const id = requestId++ const finished = new Set() + seenRequest = true pendingRequests++ lastActivity = Date.now() @@ -468,7 +478,8 @@ function setupRequestTracker(apiPattern: string | string[], idleMs = 300) { return { waitForIdle: () => { - cy.wrap(null).should(() => { + cy.wrap(null, { timeout: Cypress.config('responseTimeout') }).should(() => { + expect(seenRequest, `${label} has seen requests`).to.eq(true) expect(pendingRequests, `${label} pending requests`).to.eq(0) expect(Date.now() - lastActivity, `${label} idle time`).to.be.greaterThan(idleMs) }) @@ -712,7 +723,9 @@ export function createResourceSpec({ name, description, characteristics = [] }: cy.getBySel('resSpecNext').click() // Step 3: Finish - cy.getBySel('resSpecFinish').should('be.enabled').click() + waitForInitialPaginatedList('**/resource/resourceSpecification?*', () => { + cy.getBySel('resSpecFinish').should('be.enabled').click() + }) // Close feedback modal if it appears cy.closeFeedbackModalIfVisible() @@ -725,6 +738,8 @@ export function createResourceSpec({ name, description, characteristics = [] }: * Update resource spec status */ export function updateResourceSpecStatus({ name, status }: UpdateResourceSpecStatusParams): void { + cy.intercept('PATCH', '**/resource/resourceSpecification/**').as('patchResourceSpec') + cy.getBySel('resSpecTable').contains(name).parents('[data-cy="resSpecRow"]').find('[data-cy="resourceSpecEdit"]').click() if (status === 'launched') { @@ -736,6 +751,7 @@ export function updateResourceSpecStatus({ name, status }: UpdateResourceSpecSta cy.getBySel('resSpecUpdateNext').click() // Go to Summary step cy.getBySel('resourceSpecUpdate').click() + cy.wait('@patchResourceSpec').its('response.statusCode').should('eq', 200) // Close feedback modal if it appears cy.closeFeedbackModalIfVisible() diff --git a/cypress/support/global-state-flows.ts b/cypress/support/global-state-flows.ts index 930f0ac..041a05b 100644 --- a/cypress/support/global-state-flows.ts +++ b/cypress/support/global-state-flows.ts @@ -104,7 +104,11 @@ export function setupGlobalStateBeforeEach(params: GlobalStateSetupParams & { au cy.intercept('POST', '**/shoppingCart/item/').as('postCart') cy.intercept('POST', '**/ordering/productOrder').as('createOrder') cy.intercept('GET', '**/ordering/productOrder*').as('getOrders') + cy.intercept('GET', '**/ordering/productOrder?*relatedParty.role=seller*').as('getProviderOrders') cy.intercept('GET', '**/account/billingAccount*').as('getBilling') + cy.intercept('GET', '**/catalog/productOffering?*', (request) => { + delete request.headers['if-none-match'] + }) cy.intercept('PATCH', '**/ordering/productOrder/**').as('patchOrder') cy.loginAsAdmin() @@ -152,20 +156,27 @@ export function setupGlobalStateBeforeEach(params: GlobalStateSetupParams & { au cy.wait('@postCart') //MANUAL + const cartReadyTracker = createRequestTracker([ + '**/shoppingCart/item/', + '**/catalog/productOffering/**', + '**/catalog/productSpecification/**', + ], 500) openOfferingDrawer(offeringManualName) // Select the drawer that contains the manual offering name - cy.contains('[data-cy="toCartDrawer"]', `Adding ${offeringManualName} to cart`).within(() => { - cy.contains(HAPPY_JOURNEY.pricePlan.name).click() - cy.getBySel('acceptTermsCheckbox').click() - cy.getBySel('addToCart').click() + cartReadyTracker.waitForAction(() => { + cy.contains('[data-cy="toCartDrawer"]', `Adding ${offeringManualName} to cart`).within(() => { + cy.contains(HAPPY_JOURNEY.pricePlan.name).click() + cy.getBySel('acceptTermsCheckbox').click() + cy.getBySel('addToCart').click() + }) }) cy.wait('@postOrder') cy.wait('@postCart') cy.getBySel('shoppingCart').click() - cy.getBySel('cartPurchase').click() + cy.getBySel('cartPurchase').should('be.visible').should('not.be.disabled').click() - cy.intercept('POST', '**/ordering/productOrder').as('createOrder') + cy.deferPaymentRedirect() cy.wait('@getBilling') cy.getBySel('checkout').should('be.visible').should('not.be.disabled').click() @@ -177,9 +188,10 @@ export function setupGlobalStateBeforeEach(params: GlobalStateSetupParams & { au cy.visit('/product-orders') cy.wait('@getOrders') - cy.getBySel('asProviderTab').click() - cy.wait('@getOrders') - cy.wait('@getOrders') + cy.getBySel('ordersTable').should('be.visible') + cy.getBySel('asProviderTab').should('be.visible').click() + cy.wait('@getProviderOrders') + cy.wait(2000) cy.getBySel('ordersTable', { timeout: 60000 }).should('be.visible') // Find the most recent order (first row) and acknowledge it diff --git a/docker-speed/Dockerfile.charging b/docker-speed/Dockerfile.charging index 108e56d..d77b0bc 100644 --- a/docker-speed/Dockerfile.charging +++ b/docker-speed/Dockerfile.charging @@ -1,4 +1,4 @@ -FROM ubuntu:20.04 +FROM ubuntu:24.04 LABEL MAINTAINER="Future Internet Consulting and Development Solutions S.L." @@ -12,9 +12,23 @@ WORKDIR /opt/business-ecosystem-charging-backend/ COPY requirements.txt requirements.txt COPY dev-requirements.txt dev-requirements.txt -RUN apt-get update && apt-get -y install python3 python3-pip gcc git wkhtmltopdf xvfb wget cron && \ - pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt && \ - mkdir -p ./src/media/bills ./src/media/assets ./src/plugins +RUN apt-get update && apt-get -y install --no-install-recommends \ + cron \ + gcc \ + git \ + software-properties-common \ + wget \ + wkhtmltopdf \ + xvfb && \ + add-apt-repository -y ppa:deadsnakes/ppa && \ + apt-get update && \ + apt-get -y install --no-install-recommends python3.9 python3.9-distutils && \ + wget -O /tmp/get-pip.py https://bootstrap.pypa.io/pip/3.9/get-pip.py && \ + python3.9 /tmp/get-pip.py && \ + python3.9 -m pip install --no-cache-dir --ignore-installed -r requirements.txt && \ + python3.9 -m pip install --no-cache-dir --ignore-installed -r dev-requirements.txt && \ + mkdir -p ./src/media/bills ./src/media/assets ./src/plugins && \ + rm -rf /tmp/get-pip.py /var/lib/apt/lists/* COPY src/ src/ @@ -23,4 +37,3 @@ COPY ./docker/entrypoint.sh / EXPOSE 8006 ENTRYPOINT ["/entrypoint.sh"] - diff --git a/proxy-docker/docker-compose.yml b/proxy-docker/docker-compose.yml index c60b6d6..ebdbc86 100755 --- a/proxy-docker/docker-compose.yml +++ b/proxy-docker/docker-compose.yml @@ -34,6 +34,7 @@ services: # Service Configuration - BAE_LP_PORT=8004 - BAE_LP_HOST=0.0.0.0 + - BAE_LP_CORS_EXPOSE_REDIRECT=true # Charging Backend Connection - BAE_LP_CHARGING_SERVER=http://charging.docker:8006 diff --git a/reload.sh b/reload.sh index 7ff554b..a42086c 100755 --- a/reload.sh +++ b/reload.sh @@ -19,6 +19,10 @@ cd proxy-docker docker compose down cd .. +cd billing-server +docker compose down +cd .. + cd proxy-repo echo -e "\033[35mrecreating proxy image\033[0m" docker build -t proxy-system-dev -f docker/Dockerfile . @@ -36,3 +40,8 @@ cd proxy-docker docker compose up -d cd .. +# Start Billing Server +echo -e "\033[35mStarting Billing Server...\033[0m" +cd billing-server +docker compose up -d --build +cd ..