@autobusal/routes-order 1.34.0 → 1.34.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 1.34.2 - 2026-08-29
2
+
3
+ - With more than one card gateway available, each Credit Card button carries its processor's name - they used to render byte-identical (audit A0-06).
4
+
1
5
  # Changelog
2
6
 
3
7
  ## 1.34.0 (2026-08-29)
@@ -15,7 +15,7 @@ import Addons from '../Step5/Addons/Addons';
15
15
  import Billing from '../Step5/Billing/Billing';
16
16
  import Payments from '../Step5/Payments/Payments';
17
17
  import Sidebar from './Sidebar/Sidebar';
18
- import { getTotal, convertCoupon, getAvailablePayments, currencyOf } from '../Step5/utilities';
18
+ import { getTotal, convertCoupon, getAvailablePayments, currencyOf, surchargeOf } from '../Step5/utilities';
19
19
  import { Title, Actions, Results, Listing } from '../styles';
20
20
  import { RoutesSearchForm } from '@autobusal/routes-search/types';
21
21
  import { PersonData, BillingData } from '@autobusal/providers/types/persons';
@@ -264,6 +264,35 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
264
264
  value: total.value + addonsValue
265
265
  };
266
266
 
267
+ /*
268
+ * Claude - 2026-08-29 (ruling by Ferjolt)
269
+ *
270
+ * THE CARD SURCHARGE, shown the moment a card method is picked rather
271
+ * than sprung on the buyer at the bank's own page: "Once selected a
272
+ * payment method, will show at it +{real value}, and not showing it only
273
+ * on the card filling details."
274
+ *
275
+ * Zero until a method is chosen, and zero forever for funds, cash,
276
+ * reserve and complimentary - see surchargeOf, which only recognises the
277
+ * five gateways obtapi actually surcharges.
278
+ */
279
+ const surcharge = surchargeOf(grandTotal.value, action, paymentsData);
280
+
281
+ /*
282
+ * What the buyer is actually asked for. Rounded rather than summed
283
+ * straight into the template: 19.90 + 1.00 is not 20.90 in IEEE-754, and
284
+ * a total rendering as 20.900000000000002 is the sort of thing that
285
+ * reaches a screenshot before it reaches a test.
286
+ *
287
+ * The un-surcharged `grandTotal` is what still goes to GA4 below - the
288
+ * value of the SALE is the tickets and the add-ons, and folding a card
289
+ * fee into it would quietly inflate revenue for anyone paying by card.
290
+ */
291
+ const payable: TotalData = surcharge > 0 ? {
292
+ display: `${ Math.round((grandTotal.value + surcharge) * 100) / 100 } ${ currency }`,
293
+ value: Math.round((grandTotal.value + surcharge) * 100) / 100
294
+ } : grandTotal;
295
+
267
296
  /**
268
297
  * Edited: Ferjolt Ozuni - Date: 2026-08-05
269
298
  *
@@ -469,7 +498,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
469
498
  fare={ getTotal(step2, step3).display }
470
499
  addons={ addonItems.map(item => ({ label: item.label, value: `${ item.price } ${ currency }` })) }
471
500
  coupon={ coupon }
472
- total={ grandTotal }
501
+ total={ payable }
502
+ surcharge={ surcharge > 0 ? `${ surcharge } ${ currency }` : undefined }
473
503
  action={ action }
474
504
  single={ singleMethod }
475
505
  pending={ isPending }
@@ -480,6 +510,8 @@ const Checkout = ({ values, step1, step2, step3, isPartner, t, onStore, onBack }
480
510
  selected={ action ?? '' }
481
511
  isPartner={ isPartner ?? false }
482
512
  routeId={ step2.id }
513
+ total={ grandTotal.value }
514
+ currency={ currency }
483
515
  t={ t }
484
516
  onChange={ onAction }
485
517
  />
@@ -25,6 +25,15 @@ interface Props {
25
25
  addons: AddonItem[]
26
26
  coupon?: CouponData
27
27
  total: TotalData
28
+ /**
29
+ * The card surcharge already included in `total`, formatted with its
30
+ * currency - undefined when the chosen method carries none.
31
+ *
32
+ * Claude - 2026-08-29 (ruling by Ferjolt): the fee is stated as its own
33
+ * line rather than folded silently into the total, so the buyer can see
34
+ * what changed when they picked a card.
35
+ */
36
+ surcharge?: string
28
37
  action?: string
29
38
  single?: boolean
30
39
  pending: boolean
@@ -46,7 +55,7 @@ interface Props {
46
55
  * page and scrolled away while add-ons were being ticked - so the number
47
56
  * changed out of sight of the person it was changing for.
48
57
  */
49
- const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
58
+ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, surcharge, action, single, pending, t, onPay, children, payments }: Props): JSX.Element => (
50
59
  <Container>
51
60
  <Panel className="box">
52
61
  <SubTitle>
@@ -79,6 +88,15 @@ const Sidebar = ({ step1, step2, step3, fare, addons, coupon, total, action, sin
79
88
 
80
89
  { coupon && <Amount type="discount" code={ coupon.code } value={ coupon.discount } t={ t } /> }
81
90
 
91
+ { /* Claude - 2026-08-29: below the discount and above the total,
92
+ because it is the last thing added to what is owed - and it is
93
+ the only line here that appears and disappears as the payment
94
+ method changes, which reads as cause and effect when it sits
95
+ directly under the picker's own effect on the total. */ }
96
+ { surcharge && (
97
+ <Amount type="addon" label={ t('routes_order.step5.summary.surcharge') } value={ surcharge } t={ t } />
98
+ ) }
99
+
82
100
  <Amount type="total" value={ total.display } t={ t } />
83
101
  </div>
84
102
 
@@ -7,21 +7,57 @@ import { Inline } from '@autobusal/common';
7
7
  import { SubTitle } from '../../styles';
8
8
  import { Container, ContainerPayments, ButtonPayment } from './styles';
9
9
  import { useGetPayments } from '../../services';
10
- import { getAvailablePayments } from '../utilities';
10
+ import { getAvailablePayments, surchargeOf } from '../utilities';
11
11
 
12
12
  interface Props {
13
13
  selected: string
14
14
  isPartner: boolean
15
15
  routeId?: number | string
16
+ /**
17
+ * The checkout total the surcharge is drawn against, and its currency.
18
+ *
19
+ * Claude - 2026-08-29 (ruling by Ferjolt): each card button says what it
20
+ * adds, so the cost of choosing a card is visible BEFORE it is chosen -
21
+ * the summary line can only ever explain the method already picked.
22
+ */
23
+ total?: number
24
+ currency?: string
16
25
  t: TFunction<'common'>
17
26
  onChange: (action: string) => void
18
27
  }
19
28
 
20
- const Payments = ({ selected, isPartner, routeId, t, onChange }: Props): JSX.Element | null => {
29
+ const Payments = ({ selected, isPartner, routeId, total, currency, t, onChange }: Props): JSX.Element | null => {
21
30
  const { data, isLoading, isSuccess } = useGetPayments(routeId);
22
31
 
32
+ /*
33
+ * What a given card gateway would add to this basket, as a "+1.00 EUR"
34
+ * suffix - or nothing at all when the brand charges no surcharge on it,
35
+ * which is every gateway until somebody configures one.
36
+ */
37
+ const fee = (method: string): string => {
38
+ const value = surchargeOf(total ?? 0, method, data);
39
+
40
+ return value > 0 ? ` +${ value }${ currency ? ` ${ currency }` : '' }` : '';
41
+ };
42
+
23
43
  const available = getAvailablePayments(data, isPartner);
24
44
 
45
+ /*
46
+ * Claude - 2026-08-29 (audit A0-06, medium): a brand with several card
47
+ * gateways rendered N byte-identical "Credit Card" buttons - same icon,
48
+ * same words - so the buyer could not tell the options apart (and could
49
+ * not know which processor's surcharge they were accepting). With more
50
+ * than one card option each button now carries its processor's name;
51
+ * a single card gateway keeps the plain label it always had.
52
+ */
53
+ const CARD_GATEWAYS = ['bkt', 'stripe', 'mollie', 'raiffeisen', 'nexi'] as const;
54
+ const cardCount = isPartner ? CARD_GATEWAYS.length : CARD_GATEWAYS.filter(gateway => data?.[gateway]).length;
55
+ const CARD_NAMES: Record<string, string> = { bkt: 'BKT', stripe: 'Stripe', mollie: 'Mollie', raiffeisen: 'Raiffeisen', nexi: 'Nexi' };
56
+ const cardLabel = (gateway: string): string => (
57
+ cardCount > 1 ? `${ t('routes_order.step5.actions.card') } (${ CARD_NAMES[gateway] })` : t('routes_order.step5.actions.card')
58
+ );
59
+
60
+
25
61
  useEffect(() => {
26
62
  if (isSuccess) {
27
63
  const found = Object.keys(data).find(item => {
@@ -101,35 +137,35 @@ const Payments = ({ selected, isPartner, routeId, t, onChange }: Props): JSX.Ele
101
137
  { (data?.bkt || isPartner) && (
102
138
  <ButtonPayment type="button" $selected={ selected === 'bkt' } onClick={ () => onChange('bkt') }>
103
139
  <AiOutlineCreditCard />
104
- { t('routes_order.step5.actions.card') }
140
+ { cardLabel('bkt') }{ fee('bkt') }
105
141
  </ButtonPayment>
106
142
  ) }
107
143
 
108
144
  { (data?.stripe || isPartner) && (
109
145
  <ButtonPayment type="button" $selected={ selected === 'stripe' } onClick={ () => onChange('stripe') }>
110
146
  <AiOutlineCreditCard />
111
- { t('routes_order.step5.actions.card') }
147
+ { cardLabel('stripe') }{ fee('stripe') }
112
148
  </ButtonPayment>
113
149
  ) }
114
150
 
115
151
  { (data?.mollie || isPartner) && (
116
152
  <ButtonPayment type="button" $selected={ selected === 'mollie' } onClick={ () => onChange('mollie') }>
117
153
  <AiOutlineCreditCard />
118
- { t('routes_order.step5.actions.card') }
154
+ { cardLabel('mollie') }{ fee('mollie') }
119
155
  </ButtonPayment>
120
156
  ) }
121
157
 
122
158
  { (data?.raiffeisen || isPartner) && (
123
159
  <ButtonPayment type="button" $selected={ selected === 'raiffeisen' } onClick={ () => onChange('raiffeisen') }>
124
160
  <AiOutlineCreditCard />
125
- { t('routes_order.step5.actions.card') }
161
+ { cardLabel('raiffeisen') }{ fee('raiffeisen') }
126
162
  </ButtonPayment>
127
163
  ) }
128
164
 
129
165
  { (data?.nexi || isPartner) && (
130
166
  <ButtonPayment type="button" $selected={ selected === 'nexi' } onClick={ () => onChange('nexi') }>
131
167
  <AiOutlineCreditCard />
132
- { t('routes_order.step5.actions.card') }
168
+ { cardLabel('nexi') }{ fee('nexi') }
133
169
  </ButtonPayment>
134
170
  ) }
135
171
  </ContainerPayments>
@@ -77,3 +77,42 @@ export const getAvailablePayments = (data?: ActionData, isPartner = false): stri
77
77
 
78
78
  return available;
79
79
  };
80
+
81
+ /**
82
+ * The card surcharge on a checkout total, for the method the buyer picked.
83
+ *
84
+ * Claude - 2026-08-29 (ruling by Ferjolt)
85
+ *
86
+ * Settling by card costs us the gateway's cut, and that cut is the payer's
87
+ * rather than ours - "We do not have to support from our fund the payment
88
+ * gateway fees". So the moment a card method is selected, the checkout says
89
+ * what it adds, instead of the buyer meeting a bigger number on the bank's
90
+ * own page.
91
+ *
92
+ * ZERO FOR EVERYTHING THAT IS NOT A CARD. `funds`, `cash`, `reserve` and
93
+ * `complimentary` move no card money, and the map the server sends only ever
94
+ * contains the five gateways - the same allowlist obtapi surcharges on, so
95
+ * an unrecognised method here answers 0 rather than guessing.
96
+ *
97
+ * ROUNDED TO THE CENT, the same round-half-up on the same base the server
98
+ * charges, so the figure the buyer agreed to on this screen is the figure
99
+ * the gateway asks for. Money that disagrees between the two by a cent is
100
+ * a support ticket, and it is the kind nobody can ever reproduce.
101
+ */
102
+ export const surchargeOf = (total: number, method?: string, data?: ActionData): number => {
103
+ if (!method || !data?.surcharges) {
104
+ return 0;
105
+ }
106
+
107
+ const percent = data.surcharges[method as keyof typeof data.surcharges] ?? 0;
108
+
109
+ if (!(percent > 0) || !(total > 0)) {
110
+ return 0;
111
+ }
112
+
113
+ // total * percent / 100, to the cent. Written as ONE rounding of
114
+ // (total * percent) because dividing first introduces a binary
115
+ // fraction the server's own round() never sees - 19.90 at 5% has to
116
+ // land on 1.00 in both languages, not 0.99 in one of them.
117
+ return Math.round(total * percent) / 100;
118
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/routes-order",
3
- "version": "1.34.0",
3
+ "version": "1.34.2",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"
package/types.ts CHANGED
@@ -48,6 +48,15 @@ export interface ActionData {
48
48
  mollie: boolean
49
49
  raiffeisen: boolean
50
50
  nexi: boolean
51
+ /**
52
+ * The card surcharge each gateway carries on this brand, as a PERCENTAGE
53
+ * (5 means 5%) - see obtapi Payments\Surcharge.
54
+ *
55
+ * Claude - 2026-08-29 (ruling by Ferjolt). Optional because an older
56
+ * obtapi does not send it, and a brand on a mixed deploy must show no
57
+ * surcharge rather than NaN.
58
+ */
59
+ surcharges?: Partial<Record<'bkt' | 'stripe' | 'mollie' | 'raiffeisen' | 'nexi', number>>
51
60
  }
52
61
 
53
62
  export interface SaveData {