Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ REACT_APP_META_PIXEL=

REACT_APP_TURNSTILE_SITE_KEY=turnstile_site_key
REACT_APP_TURNSTILE_ENABLED=false
REACT_APP_PASSWORDLESS_CHECKOUT_ENABLED=false
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"@iconscout/react-unicons": "^2.2.5",
"@internxt/css-config": "1.1.1",
"@internxt/lib": "=1.5.1",
"@internxt/sdk": "1.21.1",
"@internxt/sdk": "1.22.1",
"@internxt/ui": "=0.1.21",
"@marsidev/react-turnstile": "^1.6.1",
"@phosphor-icons/react": "^2.1.10",
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/factory/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ export class SdkFactory {
return Checkout.client(envService.getVariable('payments'), appDetails, apiSecurity);
}

public createCheckoutClientWithoutSession(): Checkout {
const appDetails = SdkFactory.getAppDetails();
return Checkout.client(envService.getVariable('payments'), appDetails, { token: '' });
}

public createBackupsClient(): Backups {
const apiUrl = this.getApiUrl();
const appDetails = SdkFactory.getAppDetails();
Expand Down
66 changes: 65 additions & 1 deletion src/app/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,70 @@
"businessPlan": "O plano empresarial não está disponível",
"postalCodeRequired": "Informe seu CEP para continuar",
"countryRequired": "Não foi possível determinar seu país de cobrança. Tente novamente mais tarde.",
"missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação"
"missingPaymentDetails": "Faltam dados de pagamento para confirmar a transação",
"paymentFailed": "Falha no pagamento"
}
},
"urgentCheckout": {
"header": {
"tagline": "Sua nuvem privada",
"europeanBased": "Com sede na Europa",
"gdprCompliant": "Em conformidade com o RGPD",
"trustedByMillions": "Milhões confiam em nós"
},
"hero": {
"offerPill": "{{percent}}% OFF — Oferta exclusiva por tempo limitado",
"offerPillNoDiscount": "Oferta exclusiva por tempo limitado",
"title": "Mantenha seus arquivos, fotos e vídeos",
"titleHighlight": "100% privados.",
"subtitle": "Tenha {{storage}} de armazenamento em nuvem privada por apenas {{price}} no primeiro mês.",
"imageAlt": "Internxt Drive em um notebook e em um celular",
"points": {
"privacy": {
"title": "Só você pode acessar seus arquivos",
"description": "A criptografia de conhecimento zero faz com que nem mesmo a Internxt possa acessar seu conteúdo."
},
"storage": {
"title": "Sua nuvem privada, protegida",
"description": "Guarde suas fotos, vídeos, documentos e arquivos pessoais com privacidade integrada."
},
"offer": {
"title": "{{percent}}% OFF — por tempo limitado",
"titleNoDiscount": "Oferta exclusiva — por tempo limitado",
"description": "Tenha {{storage}} por apenas {{price}}. Esta oferta exclusiva não vai durar."
}
},
"badges": {
"basedInEu": "Com sede na UE",
"openSource": "Código aberto",
"audited": "Auditado pela Securitum",
"privacyPriority": "Sua privacidade. Nossa prioridade."
}
},
"countdown": {
"title": "Sua oferta exclusiva de {{percent}}% OFF expira em:",
"titleNoDiscount": "Sua oferta exclusiva expira em:",
"footer": "Tenha {{storage}} por {{price}} antes que esta oferta acabe."
},
"summary": {
"discountApplied": "{{percent}}% OFF aplicado",
"estimatedTax": "Impostos estimados"
},
"form": {
"emailLabel": "Seu endereço de e-mail",
"emailPlaceholder": "voce@email.com",
"createPasswordLabel": "Crie uma senha",
"passwordLabel": "Sua senha",
"passwordPlaceholder": "Senha",
"togglePasswordVisibility": "Mostrar ou ocultar a senha",
"incompleteDetails": "Informe seu e-mail e sua senha para continuar.",
"encryptedTitle": "Seus arquivos são criptografados e privados.",
"encryptedDescription": "A Internxt não pode acessar seu conteúdo."
},
"cta": {
"button": "Tenha {{storage}} por {{price}}",
"renewalNoteMonthly": "Primeiro mês por {{price}}, depois renova por {{renewalPrice}}/mês. Cancele quando quiser.",
"renewalNoteYearly": "Primeiro ano por {{price}}, depois renova por {{renewalPrice}}/ano. Cancele quando quiser."
}
},
"general": {
Expand Down Expand Up @@ -1102,6 +1165,7 @@
},
"storageIsFullDescription": "Você não pode enviar, sincronizar nem fazer backup de arquivos.",
"noFilePreview": "Não há pré-visualização disponível para este arquivo",
"filePreviewTooLarge": "Arquivo grande demais para visualizar (mais de {{limit}})",
"errorMovingToTrash": "Ocorreu um erro ao mover os itens para a lixeira.",
"errorDeletingFromTrash": "Ocorreu um erro ao excluir os itens da lixeira.",
"connectionLostError": "Conexão com a internet perdida",
Expand Down
19 changes: 19 additions & 0 deletions src/services/account-setup.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { isAccountSetupPendingError } from '@internxt/sdk';
import { SdkFactory } from 'app/core/factory/sdk';
import { generateCaptchaToken } from 'utils';

/**
* Asks Drive to send again the email to finish the setup of an account paid from the checkout.
* Drive always answers the same way, so the caller can't tell whether the email had a pending setup.
*/
export const resendAccountSetupEmail = async (email: string): Promise<void> => {
const captchaToken = await generateCaptchaToken();
const authClient = SdkFactory.getNewApiInstance().createAuthClient({ captchaToken });
await authClient.resendAccountSetupEmail(email.toLowerCase());
};

/**
* True when the error comes from an email whose account was paid but its setup is not finished yet.
* Pass the raw error: `errorService.castError` does not keep the response `code`.
*/
export const isAccountSetupPending = (error: unknown): boolean => isAccountSetupPendingError(error);
1 change: 1 addition & 0 deletions src/services/env.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const variableList = {
recaptchaV3: 'REACT_APP_RECAPTCHA_V3',
turnstileSiteKey: 'REACT_APP_TURNSTILE_SITE_KEY',
turnstileEnabled: 'REACT_APP_TURNSTILE_ENABLED',
passwordlessCheckoutEnabled: 'REACT_APP_PASSWORDLESS_CHECKOUT_ENABLED',
shareLinksDomain: 'REACT_APP_SHARE_LINKS_DOMAIN',
proxy: 'REACT_APP_PROXY',
dontUseProxy: 'REACT_APP_DONT_USE_PROXY',
Expand Down
179 changes: 179 additions & 0 deletions src/views/Checkout/hooks/useUserPayment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,185 @@ describe('Custom hook to handle payments', () => {
expect(createPaymentIntentSpy).toHaveBeenCalled();
});

test('When the buyer has no account yet, then the purchase is recorded as their first one without looking up their invoices', async () => {
const payment = useUserPayment();
vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({
clientSecret: 'client_secret',
type: 'payment',
paymentIntentId: 'pi_123',
subscriptionId: 'sub_123',
});
vi.spyOn(paymentService, 'getInvoices').mockRejectedValue(new Error('Unauthorized'));
const localStorageSetSpy = vi.spyOn(localStorageService, 'set').mockImplementation(() => {});

await payment.handleUserPayment({
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
translate: vi.fn(),
currency: 'eur',
confirmationTokenId: 'ctoken_123',
selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any,
captchaToken: 'captcha_token',
userAddress: '1.1.1.1',
confirmPayment: vi.fn().mockResolvedValue({ error: undefined }),
confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }),
gclidStored: null,
isFirstPurchase: true,
});

expect(paymentService.getInvoices).not.toHaveBeenCalled();
expect(localStorageSetSpy).toHaveBeenCalledWith('isFirstPurchase', 'true');
});

test('When a buyer without an account purchases a subscription, then it is created through the client without a Drive session', async () => {
const payment = useUserPayment();
const createSubscriptionSpy = vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({
clientSecret: 'client_secret',
type: 'payment',
paymentIntentId: 'pi_123',
subscriptionId: 'sub_123',
});
vi.spyOn(localStorageService, 'set').mockImplementation(() => {});

await payment.handleUserPayment({
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
translate: vi.fn(),
currency: 'eur',
confirmationTokenId: 'ctoken_123',
selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any,
captchaToken: 'captcha_token',
userAddress: '1.1.1.1',
confirmPayment: vi.fn().mockResolvedValue({ error: undefined }),
confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }),
gclidStored: null,
isFirstPurchase: true,
isPasswordlessSignUp: true,
});

expect(createSubscriptionSpy).toHaveBeenCalledWith(
{
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
currency: 'eur',
captchaToken: 'captcha_token',
promoCodeId: undefined,
},
{ withoutSession: true },
);
});

test('When a buyer without an account purchases a lifetime plan, then the payment intent is created through the client without a Drive session', async () => {
const payment = useUserPayment();
const createPaymentIntentSpy = vi.spyOn(checkoutService, 'createPaymentIntent').mockResolvedValue({
clientSecret: 'client_secret',
invoiceStatus: 'open',
type: 'fiat',
id: 'pi_123',
});
vi.spyOn(localStorageService, 'set').mockImplementation(() => {});

await payment.handleUserPayment({
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
translate: vi.fn(),
currency: 'eur',
confirmationTokenId: 'ctoken_123',
selectedPlan: { price: { interval: 'lifetime', type: UserType.Individual } } as any,
captchaToken: 'captcha_token',
userAddress: '1.1.1.1',
confirmPayment: vi.fn().mockResolvedValue({ error: undefined }),
confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }),
gclidStored: null,
isFirstPurchase: true,
isPasswordlessSignUp: true,
});

expect(createPaymentIntentSpy).toHaveBeenCalledWith(
{
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
currency: 'eur',
userAddress: '1.1.1.1',
captchaToken: 'captcha_token',
promoCodeId: undefined,
},
{ withoutSession: true },
);
});

test('When a logged-in buyer purchases a subscription, then it is created through the regular client', async () => {
const payment = useUserPayment();
const createSubscriptionSpy = vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({
clientSecret: 'client_secret',
type: 'payment',
paymentIntentId: 'pi_123',
subscriptionId: 'sub_123',
});
vi.spyOn(localStorageService, 'set').mockImplementation(() => {});
vi.spyOn(paymentService, 'getInvoices').mockResolvedValue([]);

await payment.handleUserPayment({
customerId: 'customer_id',
priceId: 'price_id',
token: 'user_token',
translate: vi.fn(),
currency: 'eur',
confirmationTokenId: 'ctoken_123',
selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any,
captchaToken: 'captcha_token',
userAddress: '1.1.1.1',
confirmPayment: vi.fn().mockResolvedValue({ error: undefined }),
confirmSetupIntent: vi.fn().mockResolvedValue({ error: undefined }),
gclidStored: null,
});

expect(createSubscriptionSpy).toHaveBeenCalledWith({
customerId: 'customer_id',
priceId: 'price_id',
token: 'user_token',
currency: 'eur',
captchaToken: 'captcha_token',
promoCodeId: undefined,
});
});

test('When the buyer cancels or fails the payment confirmation, then the error is raised and they are not sent to the success page', async () => {
const payment = useUserPayment();
vi.spyOn(checkoutService, 'createSubscription').mockResolvedValue({
clientSecret: 'client_secret',
type: 'payment',
paymentIntentId: 'pi_123',
subscriptionId: 'sub_123',
});
vi.spyOn(localStorageService, 'set').mockImplementation(() => {});
const navigationServiceSpy = vi.spyOn(navigationService, 'push').mockImplementation(() => {});

const paymentAttempt = payment.handleUserPayment({
customerId: 'customer_id',
priceId: 'price_id',
token: 'payments_token',
translate: vi.fn(),
currency: 'eur',
confirmationTokenId: 'ctoken_123',
selectedPlan: { price: { interval: 'year', type: UserType.Individual } } as any,
captchaToken: 'captcha_token',
userAddress: '1.1.1.1',
confirmPayment: vi.fn().mockResolvedValue({ error: { message: 'Your card was declined' } }),
confirmSetupIntent: vi.fn(),
gclidStored: null,
isFirstPurchase: true,
});

await expect(paymentAttempt).rejects.toThrow('Your card was declined');
expect(navigationServiceSpy).not.toHaveBeenCalledWith(AppView.CheckoutSuccess);
});

test('When the plan is neither a subscription nor a lifetime, then the user is redirected to the Drive page directly', async () => {
const payment = useUserPayment();

Expand Down
Loading
Loading