@greatapps/common 1.1.738 → 1.1.740

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.
Files changed (47) hide show
  1. package/dist/components/modals/billing/RequiredBillingDataModal.mjs +448 -0
  2. package/dist/components/modals/billing/RequiredBillingDataModal.mjs.map +1 -0
  3. package/dist/components/modals/cards/AddCardModal.mjs +26 -28
  4. package/dist/components/modals/cards/AddCardModal.mjs.map +1 -1
  5. package/dist/components/modals/cards/CardFormFields.mjs +10 -9
  6. package/dist/components/modals/cards/CardFormFields.mjs.map +1 -1
  7. package/dist/i18n/messages/en-us.mjs +39 -0
  8. package/dist/i18n/messages/en-us.mjs.map +1 -1
  9. package/dist/i18n/messages/es-es.mjs +39 -0
  10. package/dist/i18n/messages/es-es.mjs.map +1 -1
  11. package/dist/i18n/messages/pt-br.mjs +40 -0
  12. package/dist/i18n/messages/pt-br.mjs.map +1 -1
  13. package/dist/index.mjs +48 -32
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/modules/accounts/actions/account-management.action.mjs +15 -0
  16. package/dist/modules/accounts/actions/account-management.action.mjs.map +1 -1
  17. package/dist/modules/accounts/hooks/use-required-billing-data.hook.mjs +26 -0
  18. package/dist/modules/accounts/hooks/use-required-billing-data.hook.mjs.map +1 -0
  19. package/dist/modules/accounts/hooks/useAccountManagement.mjs +13 -1
  20. package/dist/modules/accounts/hooks/useAccountManagement.mjs.map +1 -1
  21. package/dist/modules/accounts/utils/billing-data.mjs +36 -0
  22. package/dist/modules/accounts/utils/billing-data.mjs.map +1 -0
  23. package/dist/modules/cards/hooks/create-card.hook.mjs +11 -0
  24. package/dist/modules/cards/hooks/create-card.hook.mjs.map +1 -1
  25. package/dist/modules/cards/types.mjs +20 -1
  26. package/dist/modules/cards/types.mjs.map +1 -1
  27. package/dist/modules/subscriptions/utils/has-paid-subscription.mjs +13 -0
  28. package/dist/modules/subscriptions/utils/has-paid-subscription.mjs.map +1 -0
  29. package/dist/server.mjs +2 -0
  30. package/dist/server.mjs.map +1 -1
  31. package/package.json +1 -1
  32. package/src/components/modals/billing/RequiredBillingDataModal.tsx +548 -0
  33. package/src/components/modals/cards/AddCardModal.tsx +36 -30
  34. package/src/components/modals/cards/CardFormFields.tsx +18 -10
  35. package/src/i18n/messages/en-us.ts +40 -0
  36. package/src/i18n/messages/es-es.ts +40 -0
  37. package/src/i18n/messages/pt-br.ts +42 -0
  38. package/src/index.ts +11 -0
  39. package/src/modules/accounts/actions/account-management.action.ts +24 -0
  40. package/src/modules/accounts/hooks/use-required-billing-data.hook.ts +38 -0
  41. package/src/modules/accounts/hooks/useAccountManagement.ts +13 -0
  42. package/src/modules/accounts/types.ts +11 -0
  43. package/src/modules/accounts/utils/billing-data.ts +74 -0
  44. package/src/modules/cards/hooks/create-card.hook.ts +11 -0
  45. package/src/modules/cards/types.ts +19 -0
  46. package/src/modules/subscriptions/utils/has-paid-subscription.ts +27 -0
  47. package/src/server.ts +1 -0
@@ -0,0 +1,548 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any -- o generic Translator do next-intl não é
2
+ expressável estruturalmente; o `any` do TranslateFn é intencional e está contido neste arquivo
3
+ (mesmo padrão de modules/plans/utils/map-api-plan-to-ui.ts). */
4
+ 'use client';
5
+
6
+ import { useEffect, useMemo, useRef, useState } from 'react';
7
+ import { Controller, useForm } from 'react-hook-form';
8
+ import { zodResolver } from '@hookform/resolvers/zod';
9
+ import { useTranslations } from 'next-intl';
10
+ import { toast } from 'sonner';
11
+ import z from 'zod';
12
+ import { IconFileInvoice } from '@tabler/icons-react';
13
+
14
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/overlay/Dialog';
15
+ import { Button } from '../../ui/buttons/Button';
16
+ import { FormField } from '../../ui/form/FormField';
17
+ import { SelectField } from '../../ui/form/SelectField';
18
+ import { Toast } from '../../ui/feedback/Toast';
19
+ import { useAuth } from '../../../providers/auth.provider';
20
+ import { useViaCep } from '../../../modules/accounts/hooks/useViaCep';
21
+ import { useUpdateBillingData } from '../../../modules/accounts/hooks/useAccountManagement';
22
+ import { useRequiredBillingData } from '../../../modules/accounts/hooks/use-required-billing-data.hook';
23
+ import { COUNTRIES } from '../../../utils/countries';
24
+ import { BR_STATE_OPTIONS } from '../../../utils/constants/br-states';
25
+ import { formatCNPJ, formatCPF, formatPostalCode } from '../../../utils/format/masks';
26
+ import { isValidCNPJ, isValidCPF } from '../../../utils/validators/common';
27
+ import type { UpdateBillingDataRequest } from '../../../modules/accounts/types';
28
+
29
+ type TranslateFn = (key: any, values?: Record<string, string | number>) => string;
30
+
31
+ type BillingDataFormValues = {
32
+ personType: 'pf' | 'pj';
33
+ document: string;
34
+ financialName: string;
35
+ financialEmail: string;
36
+ cep: string;
37
+ street: string;
38
+ streetNumber: string;
39
+ complement?: string;
40
+ neighborhood: string;
41
+ city: string;
42
+ state: string;
43
+ country: string;
44
+ };
45
+
46
+ const COUNTRY_OPTIONS = COUNTRIES.map((country: { iso: string; name: string }) => ({
47
+ value: country.iso,
48
+ label: country.name,
49
+ }));
50
+
51
+ function buildBillingDataSchema(isInternational: boolean, translate: TranslateFn) {
52
+ const requiredText = z
53
+ .string()
54
+ .trim()
55
+ .min(1, translate('common.billing.requiredData.requiredField'));
56
+
57
+ const base = z.object({
58
+ personType: z.enum(['pf', 'pj']),
59
+ document: z.string().trim(),
60
+ financialName: requiredText,
61
+ financialEmail: z
62
+ .string()
63
+ .trim()
64
+ .min(1, translate('common.billing.requiredData.requiredField'))
65
+ .email(translate('common.billing.requiredData.invalidEmail')),
66
+ cep: requiredText,
67
+ street: requiredText,
68
+ streetNumber: z.string().trim(),
69
+ complement: z.string().trim().optional(),
70
+ neighborhood: z.string().trim(),
71
+ city: requiredText,
72
+ state: requiredText,
73
+ country: requiredText,
74
+ });
75
+
76
+ /* Conta internacional não tem CPF/CNPJ, número nem bairro — mesmo recorte do
77
+ * AddCardModal. Exigir esses campos aqui trancaria o cliente fora do app, já que
78
+ * o modal é bloqueante. */
79
+ if (isInternational) return base;
80
+
81
+ return base.superRefine((data, ctx) => {
82
+ if (!data.streetNumber) {
83
+ ctx.addIssue({
84
+ code: 'custom',
85
+ path: ['streetNumber'],
86
+ message: translate('common.billing.requiredData.requiredField'),
87
+ });
88
+ }
89
+
90
+ if (!data.neighborhood) {
91
+ ctx.addIssue({
92
+ code: 'custom',
93
+ path: ['neighborhood'],
94
+ message: translate('common.billing.requiredData.requiredField'),
95
+ });
96
+ }
97
+
98
+ if (data.cep.replace(/\D/g, '').length !== 8) {
99
+ ctx.addIssue({
100
+ code: 'custom',
101
+ path: ['cep'],
102
+ message: translate('common.billing.requiredData.invalidCep'),
103
+ });
104
+ }
105
+
106
+ const isCompany = data.personType === 'pj';
107
+ const digits = data.document.replace(/\D/g, '');
108
+
109
+ if (digits.length !== (isCompany ? 14 : 11)) {
110
+ ctx.addIssue({
111
+ code: 'custom',
112
+ path: ['document'],
113
+ message: isCompany
114
+ ? translate('common.billing.requiredData.invalidCnpj')
115
+ : translate('common.billing.requiredData.invalidCpf'),
116
+ });
117
+ return;
118
+ }
119
+
120
+ /* Dígito verificador, mesmo algoritmo do backend: sem isso 111.111.111-11 passa
121
+ * aqui e o PUT da conta é recusado depois, sem o usuário saber o motivo. */
122
+ const isValid = isCompany ? isValidCNPJ(digits) : isValidCPF(digits);
123
+ if (!isValid) {
124
+ ctx.addIssue({
125
+ code: 'custom',
126
+ path: ['document'],
127
+ message: isCompany
128
+ ? translate('common.billing.requiredData.invalidCnpjChecksum')
129
+ : translate('common.billing.requiredData.invalidCpfChecksum'),
130
+ });
131
+ }
132
+ });
133
+ }
134
+
135
+ function formatDocument(document: string | null | undefined, isCompany: boolean) {
136
+ if (!document) return '';
137
+ return isCompany ? formatCNPJ(document) : formatCPF(document);
138
+ }
139
+
140
+ function RequiredBillingDataModalContent() {
141
+ const translate = useTranslations();
142
+ const { user } = useAuth();
143
+ const { isRequired, account, isInternational } = useRequiredBillingData();
144
+ const { mutateAsync: updateBillingData } = useUpdateBillingData();
145
+ const { mutateAsync: lookupCep } = useViaCep();
146
+
147
+ /* O modal fecha na hora do submit e não reabre enquanto o invalidate do account
148
+ * não volta — sem isso o usuário vê o modal piscar de novo com o cache antigo. */
149
+ const [isSubmitted, setIsSubmitted] = useState(false);
150
+ const isPrefilled = useRef(false);
151
+
152
+ const schema = useMemo(
153
+ () => buildBillingDataSchema(isInternational, translate),
154
+ [isInternational, translate],
155
+ );
156
+
157
+ const form = useForm<BillingDataFormValues>({
158
+ resolver: zodResolver(schema),
159
+ mode: 'onChange',
160
+ defaultValues: {
161
+ personType: 'pf',
162
+ document: '',
163
+ financialName: '',
164
+ financialEmail: '',
165
+ cep: '',
166
+ street: '',
167
+ streetNumber: '',
168
+ complement: '',
169
+ neighborhood: '',
170
+ city: '',
171
+ state: '',
172
+ country: 'BR',
173
+ },
174
+ });
175
+
176
+ const {
177
+ register,
178
+ control,
179
+ handleSubmit,
180
+ reset,
181
+ setValue,
182
+ watch,
183
+ formState: { errors, isSubmitting, isValid },
184
+ } = form;
185
+
186
+ const personType = watch('personType');
187
+ const isCompany = personType === 'pj';
188
+
189
+ useEffect(() => {
190
+ if (!account || isPrefilled.current) return;
191
+ isPrefilled.current = true;
192
+
193
+ const accountIsCompany = account.financial_document_type === 2;
194
+ reset({
195
+ personType: accountIsCompany ? 'pj' : 'pf',
196
+ document: formatDocument(account.financial_document, accountIsCompany),
197
+ financialName: account.financial_name ?? '',
198
+ financialEmail: account.financial_email || user?.email || '',
199
+ cep: account.zipcode ?? '',
200
+ street: account.address ?? '',
201
+ streetNumber: account.address_number ?? '',
202
+ complement: account.address_complement ?? '',
203
+ neighborhood: account.neighborhood ?? '',
204
+ city: account.city ?? '',
205
+ state: account.state ?? '',
206
+ country: account.country || (isInternational ? 'US' : 'BR'),
207
+ });
208
+ }, [account, isInternational, reset, user?.email]);
209
+
210
+ const handleDocumentChange = (rawValue: string) => {
211
+ setValue('document', isCompany ? formatCNPJ(rawValue) : formatCPF(rawValue), {
212
+ shouldValidate: true,
213
+ });
214
+ };
215
+
216
+ const handlePersonTypeChange = (value: 'pf' | 'pj') => {
217
+ if (value === personType) return;
218
+ setValue('document', '');
219
+ /* shouldValidate reroda o resolver: sem isso o `isValid` fica preso no valor
220
+ * do documento antigo e o botão continua habilitado com o campo já limpo. */
221
+ setValue('personType', value, { shouldValidate: true });
222
+ };
223
+
224
+ const handleCepChange = (rawValue: string) => {
225
+ if (isInternational) {
226
+ setValue('cep', rawValue, { shouldValidate: true });
227
+ return;
228
+ }
229
+
230
+ setValue('cep', formatPostalCode(rawValue), { shouldValidate: true });
231
+
232
+ const digits = rawValue.replace(/\D/g, '');
233
+ if (digits.length !== 8) return;
234
+
235
+ lookupCep(digits)
236
+ .then((data) => {
237
+ setValue('street', data.logradouro, { shouldValidate: true });
238
+ setValue('neighborhood', data.bairro, { shouldValidate: true });
239
+ setValue('city', data.localidade, { shouldValidate: true });
240
+ setValue('state', data.uf, { shouldValidate: true });
241
+ })
242
+ .catch(() => {
243
+ // CEP não encontrado: o usuário preenche o endereço na mão.
244
+ });
245
+ };
246
+
247
+ async function onSubmit(data: BillingDataFormValues) {
248
+ const payload: UpdateBillingDataRequest = {
249
+ ...(isInternational
250
+ ? {}
251
+ : {
252
+ financial_document_type: data.personType === 'pf' ? 1 : 2,
253
+ financial_document: data.document,
254
+ }),
255
+ financial_name: data.financialName,
256
+ financial_email: data.financialEmail,
257
+ zipcode: data.cep,
258
+ address: data.street,
259
+ address_number: data.streetNumber,
260
+ address_complement: data.complement ?? '',
261
+ neighborhood: data.neighborhood,
262
+ city: data.city,
263
+ state: data.state,
264
+ country: data.country,
265
+ };
266
+
267
+ try {
268
+ await updateBillingData(payload);
269
+ setIsSubmitted(true);
270
+ toast.custom(
271
+ (t) => (
272
+ <Toast
273
+ variant="success"
274
+ message={translate('common.billing.requiredData.successToast')}
275
+ toastId={t}
276
+ />
277
+ ),
278
+ { duration: 5000 },
279
+ );
280
+ } catch (error) {
281
+ /* O backend devolve a razão exata da recusa (ex.: documento fiscal inválido).
282
+ * Trocar por um texto genérico deixaria o usuário travado sem saber o que corrigir. */
283
+ const message =
284
+ error instanceof Error && error.message
285
+ ? error.message
286
+ : translate('common.billing.requiredData.errorToast');
287
+ toast.custom((t) => <Toast variant="error" message={message} toastId={t} />, {
288
+ duration: 5000,
289
+ });
290
+ }
291
+ }
292
+
293
+ if (!isRequired || isSubmitted) return null;
294
+
295
+ return (
296
+ <Dialog open onOpenChange={() => {}}>
297
+ <DialogContent
298
+ showCloseButton={false}
299
+ onEscapeKeyDown={(event: Event) => event.preventDefault()}
300
+ onPointerDownOutside={(event: Event) => event.preventDefault()}
301
+ onInteractOutside={(event: Event) => event.preventDefault()}
302
+ className="flex flex-col p-0 gap-0 max-w-full sm:max-w-full border-0 rounded-t-2xl rounded-b-none h-dvh top-0 bottom-0 left-0 right-0 translate-x-0 translate-y-0 lg:max-w-[502px] lg:h-auto lg:max-h-[90vh] lg:rounded-lg lg:border lg:top-[50%] lg:left-[50%] lg:right-auto lg:bottom-auto lg:translate-x-[-50%] lg:translate-y-[-50%] overflow-hidden"
303
+ >
304
+ <DialogHeader className="gap-3 text-left p-4 lg:p-5 border-b border-zinc-200 shrink-0">
305
+ <div className="flex items-center justify-center w-10 h-10 bg-zinc-50 rounded-lg">
306
+ <IconFileInvoice size={24} className="text-zinc-950" />
307
+ </div>
308
+ <div className="flex flex-col gap-2">
309
+ <DialogTitle className="paragraph-medium-semibold text-zinc-950">
310
+ {translate('common.billing.requiredData.title')}
311
+ </DialogTitle>
312
+ <span className="paragraph-small-regular text-zinc-600">
313
+ {translate('common.billing.requiredData.description')}
314
+ </span>
315
+ </div>
316
+ </DialogHeader>
317
+
318
+ <form
319
+ onSubmit={handleSubmit(onSubmit)}
320
+ className="flex flex-col flex-1 min-h-0"
321
+ noValidate
322
+ >
323
+ <div className="flex flex-col gap-6 p-4 lg:p-5 flex-1 overflow-y-auto">
324
+ <div className="flex flex-col gap-5">
325
+ {!isInternational && (
326
+ <div className="flex gap-4">
327
+ <Controller
328
+ name="personType"
329
+ control={control}
330
+ render={({ field }) => (
331
+ <SelectField
332
+ label={translate('common.billing.requiredData.personType')}
333
+ placeholder={translate('common.billing.requiredData.select')}
334
+ value={field.value}
335
+ onChange={(value) => handlePersonTypeChange(value as 'pf' | 'pj')}
336
+ options={[
337
+ {
338
+ value: 'pf',
339
+ label: translate('common.billing.requiredData.personTypePf'),
340
+ },
341
+ {
342
+ value: 'pj',
343
+ label: translate('common.billing.requiredData.personTypePj'),
344
+ },
345
+ ]}
346
+ containerClassName="flex-1"
347
+ />
348
+ )}
349
+ />
350
+ <div className="flex-1">
351
+ <FormField
352
+ label={
353
+ isCompany
354
+ ? translate('common.billing.requiredData.cnpj')
355
+ : translate('common.billing.requiredData.cpf')
356
+ }
357
+ placeholder={isCompany ? '__.___.___/____-__' : '___.___.___-__'}
358
+ error={!!errors.document}
359
+ errorMessage={errors.document?.message}
360
+ {...register('document', {
361
+ onChange: (event) => handleDocumentChange(event.target.value),
362
+ })}
363
+ />
364
+ </div>
365
+ </div>
366
+ )}
367
+
368
+ <FormField
369
+ label={
370
+ isCompany
371
+ ? translate('common.billing.requiredData.companyName')
372
+ : translate('common.billing.requiredData.fullName')
373
+ }
374
+ placeholder={translate('common.billing.requiredData.typeHere')}
375
+ error={!!errors.financialName}
376
+ errorMessage={errors.financialName?.message}
377
+ {...register('financialName')}
378
+ />
379
+
380
+ <FormField
381
+ label={translate('common.billing.requiredData.financialEmail')}
382
+ placeholder={translate('common.billing.requiredData.financialEmailPlaceholder')}
383
+ type="email"
384
+ error={!!errors.financialEmail}
385
+ errorMessage={errors.financialEmail?.message}
386
+ {...register('financialEmail')}
387
+ />
388
+ </div>
389
+
390
+ <div className="h-px bg-zinc-200" />
391
+
392
+ <div className="flex flex-col gap-5">
393
+ <span className="paragraph-small-semibold text-zinc-950">
394
+ {translate('common.billing.requiredData.addressHeading')}
395
+ </span>
396
+
397
+ <FormField
398
+ label={
399
+ isInternational
400
+ ? translate('common.billing.requiredData.postalCode')
401
+ : translate('common.billing.requiredData.cep')
402
+ }
403
+ placeholder={isInternational ? '' : '_____-___'}
404
+ error={!!errors.cep}
405
+ errorMessage={errors.cep?.message}
406
+ {...register('cep', {
407
+ onChange: (event) => handleCepChange(event.target.value),
408
+ })}
409
+ />
410
+
411
+ <div className="flex gap-4">
412
+ <div className="flex-[3]">
413
+ <FormField
414
+ label={translate('common.billing.requiredData.street')}
415
+ placeholder={translate('common.billing.requiredData.typeHere')}
416
+ error={!!errors.street}
417
+ errorMessage={errors.street?.message}
418
+ {...register('street')}
419
+ />
420
+ </div>
421
+ {!isInternational && (
422
+ <div className="flex-1">
423
+ <FormField
424
+ label={translate('common.billing.requiredData.streetNumber')}
425
+ placeholder="000"
426
+ error={!!errors.streetNumber}
427
+ errorMessage={errors.streetNumber?.message}
428
+ {...register('streetNumber')}
429
+ />
430
+ </div>
431
+ )}
432
+ </div>
433
+
434
+ <div className="flex gap-4">
435
+ {!isInternational && (
436
+ <div className="flex-1">
437
+ <FormField
438
+ label={translate('common.billing.requiredData.neighborhood')}
439
+ placeholder={translate('common.billing.requiredData.typeHere')}
440
+ error={!!errors.neighborhood}
441
+ errorMessage={errors.neighborhood?.message}
442
+ {...register('neighborhood')}
443
+ />
444
+ </div>
445
+ )}
446
+ <div className="flex-1">
447
+ <FormField
448
+ label={translate('common.billing.requiredData.complement')}
449
+ optional
450
+ placeholder={translate('common.billing.requiredData.typeHere')}
451
+ {...register('complement')}
452
+ />
453
+ </div>
454
+ </div>
455
+
456
+ <div className="flex gap-4">
457
+ <div className="flex-1">
458
+ <FormField
459
+ label={translate('common.billing.requiredData.city')}
460
+ placeholder={translate('common.billing.requiredData.typeHere')}
461
+ error={!!errors.city}
462
+ errorMessage={errors.city?.message}
463
+ {...register('city')}
464
+ />
465
+ </div>
466
+ <div className="flex-1">
467
+ {isInternational ? (
468
+ <FormField
469
+ label={translate('common.billing.requiredData.stateProvince')}
470
+ placeholder={translate('common.billing.requiredData.typeHere')}
471
+ error={!!errors.state}
472
+ errorMessage={errors.state?.message}
473
+ {...register('state')}
474
+ />
475
+ ) : (
476
+ <Controller
477
+ name="state"
478
+ control={control}
479
+ render={({ field }) => (
480
+ <SelectField
481
+ label={translate('common.billing.requiredData.state')}
482
+ placeholder={translate('common.billing.requiredData.select')}
483
+ value={field.value || undefined}
484
+ onChange={(value) =>
485
+ setValue('state', value as string, { shouldValidate: true })
486
+ }
487
+ options={BR_STATE_OPTIONS}
488
+ error={!!errors.state}
489
+ errorMessage={errors.state?.message}
490
+ />
491
+ )}
492
+ />
493
+ )}
494
+ </div>
495
+ </div>
496
+
497
+ <Controller
498
+ name="country"
499
+ control={control}
500
+ render={({ field }) => (
501
+ <SelectField
502
+ label={translate('common.billing.requiredData.country')}
503
+ placeholder={translate('common.billing.requiredData.select')}
504
+ value={field.value || undefined}
505
+ onChange={(value) =>
506
+ setValue('country', value as string, { shouldValidate: true })
507
+ }
508
+ options={COUNTRY_OPTIONS}
509
+ error={!!errors.country}
510
+ errorMessage={errors.country?.message}
511
+ />
512
+ )}
513
+ />
514
+ </div>
515
+ </div>
516
+
517
+ <div className="flex items-center justify-end p-4 lg:p-5 border-t border-zinc-200 shrink-0">
518
+ <Button
519
+ type="submit"
520
+ className="h-10! w-full lg:w-fit"
521
+ disabled={!isValid || isSubmitting}
522
+ loading={isSubmitting}
523
+ >
524
+ {isSubmitting
525
+ ? translate('common.billing.requiredData.submitting')
526
+ : translate('common.billing.requiredData.submit')}
527
+ </Button>
528
+ </div>
529
+ </form>
530
+ </DialogContent>
531
+ </Dialog>
532
+ );
533
+ }
534
+
535
+ /**
536
+ * Modal bloqueante que exige o cadastro de cobrança/fiscal de contas que já
537
+ * pagaram sem ele — sem esses dados a nota fiscal não é emitida. Monte uma vez
538
+ * no layout raiz do app; ele mesmo decide se aparece (ver `useRequiredBillingData`).
539
+ */
540
+ export default function RequiredBillingDataModal() {
541
+ const { user, isLoading } = useAuth();
542
+
543
+ /* Sem sessão não há conta nem assinatura pra consultar — evita disparar as
544
+ * queries (e tomar 401) em /login, /register e demais rotas públicas. */
545
+ if (isLoading || !user) return null;
546
+
547
+ return <RequiredBillingDataModalContent />;
548
+ }
@@ -7,8 +7,8 @@ import { Toast } from '../../ui/feedback/Toast';
7
7
  import { useModalManager } from '../../../store/useModalManager';
8
8
  import { useCreateCard } from '../../../modules/cards/hooks/create-card.hook';
9
9
  import { useCreateSetupIntent } from '../../../modules/cards/hooks/create-setup-intent.hook';
10
- import { useUpdateAccount } from '../../../modules/accounts/hooks/useAccountManagement';
11
10
  import { useCurrentAccount } from '../../../modules/accounts/hooks/current-account.hook';
11
+ import { isValidTaxId } from '../../../utils/validators/common';
12
12
  import { useExternalContracting } from '../../../providers/whitelabel.provider';
13
13
  import { CardFormFields, BillingFormFields, buildCardFormSchema } from './CardFormFields';
14
14
  import type { CardFormData, StripeElementsStatus } from './CardFormFields';
@@ -89,7 +89,6 @@ export default function AddCardModal() {
89
89
 
90
90
  const createSetupIntentMutation = useCreateSetupIntent();
91
91
  const createCardMutation = useCreateCard();
92
- const updateAccountMutation = useUpdateAccount();
93
92
  const elements = useElements();
94
93
  const stripe = useStripe();
95
94
 
@@ -223,30 +222,32 @@ export default function AddCardModal() {
223
222
  return;
224
223
  }
225
224
 
226
- const [cardResult] = await Promise.all([
227
- createCardMutation.mutateAsync({
228
- setup_intent_id: intent.setup_intent_id,
229
- name: data.name,
230
- set_default: true,
231
- }),
232
- updateAccountMutation.mutateAsync({
233
- ...(isInternational
234
- ? {}
235
- : {
236
- financial_document_type: data.personType === 'pf' ? 1 : 2,
237
- financial_document: data.personType === 'pf' ? data.cpf : data.cnpj,
238
- }),
239
- financial_name: data.fullName,
240
- zipcode: data.cep,
241
- address: data.street,
242
- address_number: data.streetNumber || '',
243
- address_complement: data.complement || '',
244
- neighborhood: data.neighborhood || '',
245
- city: data.city,
246
- state: data.state,
247
- country: data.country,
248
- }),
249
- ]);
225
+ /* Chamada ÚNICA: os dados fiscais vão no mesmo request do cartão. O backend atualiza a
226
+ * conta ANTES de registrar o cartão, fail-closed — fiscal rejeitado (CPF inválido etc.)
227
+ * = cartão não registra e o erro chega com a mensagem específica no catch abaixo.
228
+ * Substitui o Promise.all de createCard + updateAccount, que não tinha garantia entre as
229
+ * pernas: quando o PUT de conta falhava, o cartão ficava salvo e a conta sem NENHUM dado
230
+ * fiscal — e as faturas seguintes falhavam a emissão de nota em silêncio. */
231
+ const cardResult = await createCardMutation.mutateAsync({
232
+ setup_intent_id: intent.setup_intent_id,
233
+ name: data.name,
234
+ set_default: true,
235
+ ...(isInternational
236
+ ? {}
237
+ : {
238
+ financial_document_type: data.personType === 'pf' ? 1 : 2,
239
+ financial_document: data.personType === 'pf' ? data.cpf : data.cnpj,
240
+ }),
241
+ financial_name: data.fullName,
242
+ zipcode: data.cep,
243
+ address: data.street,
244
+ address_number: data.streetNumber || '',
245
+ address_complement: data.complement || '',
246
+ neighborhood: data.neighborhood || '',
247
+ city: data.city,
248
+ state: data.state,
249
+ country: data.country,
250
+ });
250
251
 
251
252
  if (cardResult.success && cardResult.data?.id != null) {
252
253
  onCardAdded?.(cardResult.data.id.toString());
@@ -257,12 +258,16 @@ export default function AddCardModal() {
257
258
  { duration: 5000 }
258
259
  );
259
260
  handleClose();
260
- } catch {
261
+ } catch (error) {
262
+ /* O backend fail-closed devolve a razão exata da recusa (ex.: "CPF do pagador inválido —
263
+ * confira os dígitos verificadores"), e o withAction relança como Error com essa mensagem.
264
+ * Engolir num texto genérico deixaria o usuário sem saber o que corrigir. */
265
+ const backendMessage = error instanceof Error && error.message ? error.message : null;
261
266
  toast.custom(
262
267
  (t) => (
263
268
  <Toast
264
269
  variant="error"
265
- message={translate('common.cards.add.errorSave')}
270
+ message={backendMessage || translate('common.cards.add.errorSave')}
266
271
  toastId={t}
267
272
  />
268
273
  ),
@@ -275,7 +280,6 @@ export default function AddCardModal() {
275
280
 
276
281
  const isSubmitting =
277
282
  createCardMutation.isPending ||
278
- updateAccountMutation.isPending ||
279
283
  form.formState.isSubmitting;
280
284
 
281
285
  const watchedValues = form.watch(['name', 'country', 'personType', 'cpf', 'cnpj', 'fullName', 'cep', 'street', 'streetNumber', 'neighborhood', 'city', 'state']);
@@ -294,7 +298,9 @@ export default function AddCardModal() {
294
298
  : !!name &&
295
299
  !!country &&
296
300
  !!personType &&
297
- (personType === 'pf' ? !!cpf && cpf.length >= 14 : !!cnpj && cnpj.length >= 18) &&
301
+ /* Dígito verificador, em sincronia com o schema (buildCardFormSchema) e com a validação
302
+ * do backend — a regra antiga de tamanho deixava 111.111.111-11 habilitar o botão. */
303
+ (personType === 'pf' ? isValidTaxId(cpf, 'cpf') : isValidTaxId(cnpj, 'cnpj')) &&
298
304
  stripeStatus.cardNumber &&
299
305
  stripeStatus.cardExpiry &&
300
306
  stripeStatus.cardCvc;