From 8d677f80f083697765f99ab76481e07d65954ae9 Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 5 Oct 2026 15:52:02 -0300 Subject: [PATCH 1/2] fix(paghiper): Show banking billet unless disabled and respect minimum amount on Pix and billet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banking billet only appeared when "disable billet" was checked: the flag was read without negation. Now it is listed unless `pix.disable_billet` is set, as in the legacy app. The configured minimum amount was never applied because of operator precedence (`min_amount || isPix ? 3 : 0`). Now it applies to both methods, keeping the R$ 3 floor for Pix. Pix transactions now return `account_deposit.valid_thru`, so the checkout shows the countdown. PagHiper due dates are read as Brasília time (date-only means end of day), also for the billet `valid_thru`. Unit tests with stubbed config: `bash scripts/tests.sh` after `pnpm build`. Co-Authored-By: Claude Opus 5.5 --- packages/apps/paghiper/scripts/tests.sh | 16 +++++ .../paghiper/src/functions-lib/due-date.ts | 20 ++++++ .../src/paghiper-create-transaction.ts | 11 +++- .../paghiper/src/paghiper-list-payments.ts | 6 +- .../tests/list-payments-and-due-date.test.mjs | 66 +++++++++++++++++++ packages/apps/paghiper/tests/settings.json | 6 ++ 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 packages/apps/paghiper/scripts/tests.sh create mode 100644 packages/apps/paghiper/src/functions-lib/due-date.ts create mode 100644 packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs create mode 100644 packages/apps/paghiper/tests/settings.json diff --git a/packages/apps/paghiper/scripts/tests.sh b/packages/apps/paghiper/scripts/tests.sh new file mode 100644 index 000000000..67bca9c08 --- /dev/null +++ b/packages/apps/paghiper/scripts/tests.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# List payments and due date parsing are pure, no PagHiper nor Store API +# credentials needed, just stub env vars for `@cloudcommerce/firebase` config. +export SETTINGS_FILEPATH="$(pwd)/tests/settings.json" +export ECOM_STORE_ID="${ECOM_STORE_ID:-1011}" +export ECOM_AUTHENTICATION_ID="${ECOM_AUTHENTICATION_ID:-000000000000000000000000}" +export ECOM_API_KEY="${ECOM_API_KEY:-test}" +export PAGHIPER_API_KEY="${PAGHIPER_API_KEY:-apk_test}" + +if [ ! -d lib ]; then + echo -e "Run \`pnpm build\` before testing\n" + exit 1 +fi + +node --test tests/ diff --git a/packages/apps/paghiper/src/functions-lib/due-date.ts b/packages/apps/paghiper/src/functions-lib/due-date.ts new file mode 100644 index 000000000..4fb96dda9 --- /dev/null +++ b/packages/apps/paghiper/src/functions-lib/due-date.ts @@ -0,0 +1,20 @@ +/** + * PagHiper returns due dates in Brasília time, with or without the time part + * ("2026-10-08" or "2026-10-08 23:59:59"). `new Date()` alone would read them + * as UTC, ending the checkout countdown three hours early (or the day before). + */ +const parseDueDate = (dueDate?: string | null) => { + if (!dueDate) return undefined; + const value = String(dueDate).trim(); + let date: Date; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + date = new Date(`${value}T23:59:59-03:00`); + } else if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2})?$/.test(value)) { + date = new Date(`${value.replace(' ', 'T')}-03:00`); + } else { + date = new Date(value); + } + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +}; + +export default parseDueDate; diff --git a/packages/apps/paghiper/src/paghiper-create-transaction.ts b/packages/apps/paghiper/src/paghiper-create-transaction.ts index 5528d8965..50228b739 100644 --- a/packages/apps/paghiper/src/paghiper-create-transaction.ts +++ b/packages/apps/paghiper/src/paghiper-create-transaction.ts @@ -6,6 +6,7 @@ import type { import type { PagHiperApp } from '../types/config-app'; import config, { logger } from '@cloudcommerce/firebase/lib/config'; import createAxios from './functions-lib/create-axios'; +import parseDueDate from './functions-lib/due-date'; type ItemsPagHiper = { description: string, @@ -191,6 +192,11 @@ export default async (appData: AppModuleBody) => { break; } } + const validThru = parseDueDate(createRequest.due_date); + if (validThru) { + // Checkout shows the Pix countdown from `valid_thru` + transaction.account_deposit = { valid_thru: validThru }; + } transaction.notes = `'; } else { @@ -200,8 +206,9 @@ export default async (appData: AppModuleBody) => { code: bankSlip.digitable_line, link: bankSlip.url_slip_pdf, }; - if (createRequest.due_date) { - transaction.banking_billet.valid_thru = new Date(createRequest.due_date).toISOString(); + const validThru = parseDueDate(createRequest.due_date); + if (validThru) { + transaction.banking_billet.valid_thru = validThru; } } diff --git a/packages/apps/paghiper/src/paghiper-list-payments.ts b/packages/apps/paghiper/src/paghiper-list-payments.ts index 50a6309d9..d7d15a82b 100644 --- a/packages/apps/paghiper/src/paghiper-list-payments.ts +++ b/packages/apps/paghiper/src/paghiper-list-payments.ts @@ -60,10 +60,12 @@ export default async (data: AppModuleBody) => { listPaymentMethods.forEach((paymentMethod) => { const isPix = paymentMethod === 'account_deposit'; - const minAmount = configApp.min_amount || isPix ? 3 : 0; + // Configured minimum applies to both methods, Pix has a R$ 3 floor on PagHiper + const minAmount = Math.max(configApp.min_amount || 0, isPix ? 3 : 0); const methodConfig = isPix ? configApp.pix : configApp; - const methodEnable = isPix ? configApp?.pix?.enable : configApp?.pix?.disable_billet; + // Banking billet is listed unless explicitly disabled on Pix options + const methodEnable = isPix ? configApp?.pix?.enable : !configApp?.pix?.disable_billet; // Workaround for showcase const validateAmount = amount.total ? (amount.total >= minAmount) : true; diff --git a/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs b/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs new file mode 100644 index 000000000..f22e457f0 --- /dev/null +++ b/packages/apps/paghiper/tests/list-payments-and-due-date.test.mjs @@ -0,0 +1,66 @@ +/* eslint-disable import/no-relative-packages */ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import listPayments from '../lib/paghiper-list-payments.js'; +import parseDueDate from '../lib/functions-lib/due-date.js'; + +process.env.PAGHIPER_API_KEY = process.env.PAGHIPER_API_KEY || 'apk_test'; + +const list = (data, total) => listPayments({ + application: { data, hidden_data: {} }, + params: { lang: 'pt_br', amount: { total } }, +}); +const codes = (res) => res.payment_gateways.map((g) => g.payment_method.code); + +describe('PagHiper list payments', () => { + test('lists banking billet by default (no Pix config)', async () => { + assert.deepStrictEqual(codes(await list({}, 100)), ['banking_billet']); + }); + + test('lists billet and Pix when Pix is enabled', async () => { + assert.deepStrictEqual( + codes(await list({ pix: { enable: true } }, 100)), + ['banking_billet', 'account_deposit'], + ); + }); + + test('hides banking billet only when disable_billet is set', async () => { + assert.deepStrictEqual( + codes(await list({ pix: { enable: true, disable_billet: true } }, 100)), + ['account_deposit'], + ); + }); + + test('respects configured minimum amount for both methods', async () => { + const config = { min_amount: 200, pix: { enable: true } }; + assert.deepStrictEqual(codes(await list(config, 150)), []); + assert.deepStrictEqual(codes(await list(config, 250)), ['banking_billet', 'account_deposit']); + }); + + test('keeps R$ 3 floor for Pix without configured minimum', async () => { + assert.deepStrictEqual(codes(await list({ pix: { enable: true } }, 2)), ['banking_billet']); + }); + + test('lists both on showcase requests without amount', async () => { + assert.deepStrictEqual( + codes(await list({ min_amount: 200, pix: { enable: true } })), + ['banking_billet', 'account_deposit'], + ); + }); +}); + +describe('PagHiper due date', () => { + test('date only means end of day in Brasília', () => { + assert.strictEqual(parseDueDate('2026-10-08'), '2026-10-09T02:59:59.000Z'); + }); + + test('date and time are Brasília time', () => { + assert.strictEqual(parseDueDate('2026-10-08 15:30:00'), '2026-10-08T18:30:00.000Z'); + }); + + test('keeps ISO with offset and ignores invalid', () => { + assert.strictEqual(parseDueDate('2026-10-08T15:30:00Z'), '2026-10-08T15:30:00.000Z'); + assert.strictEqual(parseDueDate('amanhã'), undefined); + assert.strictEqual(parseDueDate(undefined), undefined); + }); +}); diff --git a/packages/apps/paghiper/tests/settings.json b/packages/apps/paghiper/tests/settings.json new file mode 100644 index 000000000..b39c46052 --- /dev/null +++ b/packages/apps/paghiper/tests/settings.json @@ -0,0 +1,6 @@ +{ + "name": "PagHiper test store", + "lang": "pt_br", + "currency": "BRL", + "currency_symbol": "R$" +} From 71f9069f5f06b66f126967ec1ff557f36e6133ad Mon Sep 17 00:00:00 2001 From: Vitor Date: Mon, 5 Oct 2026 15:55:01 -0300 Subject: [PATCH 2/2] feat(custom-shipping): Allow minimum shipping price on percentage and weight rules Stores that charge a percentage of the cart (e.g. 3% with a R$ 150 floor) couldn't set a floor: when two rules share a service code the cheapest one wins. New `min_price` on shipping rules raises the final price to the floor after fixed price, percentage and excedent weight are applied. Rules with a floor are not counted as free shipping on the preview, and the field is removed from the returned shipping line. The admin schema snippet for the Market app is in the README. Unit tests with stubbed config: `bash scripts/tests-unit.sh` after `pnpm build`. Co-Authored-By: Claude Opus 5.5 --- packages/apps/custom-shipping/README.md | 31 ++++++++++ .../custom-shipping/scripts/tests-unit.sh | 15 +++++ .../src/custom-shipping-calculate.ts | 6 ++ .../tests-unit/min-price.test.mjs | 60 +++++++++++++++++++ .../custom-shipping/tests-unit/settings.json | 7 +++ 5 files changed, 119 insertions(+) create mode 100644 packages/apps/custom-shipping/scripts/tests-unit.sh create mode 100644 packages/apps/custom-shipping/tests-unit/min-price.test.mjs create mode 100644 packages/apps/custom-shipping/tests-unit/settings.json diff --git a/packages/apps/custom-shipping/README.md b/packages/apps/custom-shipping/README.md index 8271ddc14..e9e9d95e5 100644 --- a/packages/apps/custom-shipping/README.md +++ b/packages/apps/custom-shipping/README.md @@ -1 +1,32 @@ # `@cloudcommerce/app-custom-shipping` + +## Shipping rules + +Each item of `shipping_rules` (app data) may combine: + +| Field | Effect | +|---|---| +| `zip_range.min` / `zip_range.max` | Destination ZIP range | +| `min_amount` | Minimum cart subtotal for the rule to apply | +| `total_price` | Fixed price (or the service `total_price` when unset) | +| `amount_tax` | Percentage of the cart subtotal added to the price | +| `excedent_weight_cost` + `max_cubic_weight` | Price per kg above the weight limit | +| `min_price` | Floor for the final price, e.g. "3% of the cart, at least R$ 150" | + +When two valid rules share the same `service_code`, the cheapest wins, so a +floor can't be emulated with a second rule: use `min_price`. + +Admin settings schema for `min_price` (add to `shipping_rules.schema.items.properties` +on the Market app): + +```json +"min_price": { + "type": "number", + "minimum": 0, + "maximum": 99999999, + "title": "Valor mínimo do frete", + "description": "Piso do frete calculado (ex.: X% do carrinho, com mínimo de R$ Y)" +} +``` + +Unit tests: `pnpm build && bash scripts/tests-unit.sh`. diff --git a/packages/apps/custom-shipping/scripts/tests-unit.sh b/packages/apps/custom-shipping/scripts/tests-unit.sh new file mode 100644 index 000000000..10b2c72f4 --- /dev/null +++ b/packages/apps/custom-shipping/scripts/tests-unit.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Shipping rules math is pure, no Store API credentials needed, just stub env +# vars for `@cloudcommerce/firebase` config module. +export SETTINGS_FILEPATH="$(pwd)/tests-unit/settings.json" +export ECOM_STORE_ID="${ECOM_STORE_ID:-1011}" +export ECOM_AUTHENTICATION_ID="${ECOM_AUTHENTICATION_ID:-000000000000000000000000}" +export ECOM_API_KEY="${ECOM_API_KEY:-test}" + +if [ ! -d lib ]; then + echo -e "Run \`pnpm build\` before testing\n" + exit 1 +fi + +node --test tests-unit/ diff --git a/packages/apps/custom-shipping/src/custom-shipping-calculate.ts b/packages/apps/custom-shipping/src/custom-shipping-calculate.ts index 237863e3a..d0d084ab4 100644 --- a/packages/apps/custom-shipping/src/custom-shipping-calculate.ts +++ b/packages/apps/custom-shipping/src/custom-shipping-calculate.ts @@ -72,6 +72,7 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi && !rule.disable_free_shipping_from && !(rule.excedent_weight_cost > 0) && !(rule.amount_tax > 0) + && !(rule.min_price > 0) ) { if (!originZip && rule.from && rule.from.zip) { originZip = rule.from.zip; @@ -224,6 +225,10 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi if (typeof rule.amount_tax === 'number' && !Number.isNaN(rule.amount_tax)) { rule.total_price += ((rule.amount_tax * amount) / 100); } + // Floor for percentage or weight based rules, e.g. "X% of cart, at least R$ Y" + if (rule.min_price > 0 && rule.total_price < rule.min_price) { + rule.total_price = rule.min_price; + } const currentShippingRule = _shippingRulesByCode[serviceCode]; if (!currentShippingRule || currentShippingRule.total_price > rule.total_price) { _shippingRulesByCode[serviceCode] = rule; @@ -243,6 +248,7 @@ export const calculateShipping = async (modBody: AppModuleBody<'calculate_shippi delete rule.max_cubic_weight; delete rule.excedent_weight_cost; delete rule.amount_tax; + delete rule.min_price; delete rule.label; // also try to find corresponding service object from config diff --git a/packages/apps/custom-shipping/tests-unit/min-price.test.mjs b/packages/apps/custom-shipping/tests-unit/min-price.test.mjs new file mode 100644 index 000000000..0df0cf917 --- /dev/null +++ b/packages/apps/custom-shipping/tests-unit/min-price.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import { calculateShipping } from '../lib/custom-shipping-calculate.js'; + +// GO zip range with "3% of cart, at least R$ 150" (Bom Ar own delivery rule) +const rule = () => ({ + service_code: 'GO', + label: 'Entrega Bom Ar', + zip_range: { min: 72800000, max: 76799999 }, + amount_tax: 3, + min_price: 150, + delivery_time: { days: 3, working_days: true }, +}); + +const calc = (subtotal, zip = '74543010', rules = [rule()]) => calculateShipping({ + application: { data: { zip: '74543010', shipping_rules: rules }, hidden_data: {} }, + params: { + to: zip && { zip }, + subtotal, + items: [{ + product_id: '1', sku: 'A', name: 'Split', price: subtotal, quantity: 1, weight: { value: 30, unit: 'kg' }, + }], + }, +}); + +describe('Custom shipping minimum price', () => { + test('uses the floor when the percentage is lower', async () => { + const { shipping_services: services } = await calc(3000); + assert.strictEqual(services.length, 1); + assert.strictEqual(services[0].shipping_line.total_price, 150); + }); + + test('uses the percentage when it is higher than the floor', async () => { + const { shipping_services: services } = await calc(10000); + assert.strictEqual(services[0].shipping_line.total_price, 300); + }); + + test('does not leak min_price into the shipping line', async () => { + const { shipping_services: services } = await calc(3000); + assert.strictEqual(services[0].shipping_line.min_price, undefined); + }); + + test('keeps rules without floor unchanged', async () => { + const noFloor = { ...rule() }; + delete noFloor.min_price; + const { shipping_services: services } = await calc(3000, '74543010', [noFloor]); + assert.strictEqual(services[0].shipping_line.total_price, 90); + }); + + test('ignores destinations outside the zip range', async () => { + const { shipping_services: services } = await calc(3000, '01310100'); + assert.strictEqual(services.length, 0); + }); + + test('rule with only a floor is not a free shipping preview', async () => { + const floorOnly = { service_code: 'GO', zip_range: rule().zip_range, min_price: 150 }; + const res = await calc(3000, null, [floorOnly]); + assert.notStrictEqual(res.free_shipping_from_value, 0); + }); +}); diff --git a/packages/apps/custom-shipping/tests-unit/settings.json b/packages/apps/custom-shipping/tests-unit/settings.json new file mode 100644 index 000000000..cabb34e1c --- /dev/null +++ b/packages/apps/custom-shipping/tests-unit/settings.json @@ -0,0 +1,7 @@ +{ + "name": "Custom shipping test store", + "lang": "pt_br", + "currency": "BRL", + "currency_symbol": "R$", + "country_code": "BR" +}