Import code for payments.ente.io

Manav: To avoid accidentally spilling out any secrets, squash and
import. However, the overwhelming majority of the work in the original code was
done by Abhinav <abhinavk.grd@gmail.com> so set them as the author for the
commit.
This commit is contained in:
Abhinav 2024-03-28 10:16:55 +05:30 committed by Manav Rathi
parent 33314bc2da
commit 8a3c0743fe
No known key found for this signature in database
37 changed files with 4557 additions and 0 deletions

View file

@ -0,0 +1,13 @@
{
"presets": ["next/babel"],
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}

View file

@ -0,0 +1,3 @@
{
"extends": ["next", "next/core-web-vitals"]
}

View file

@ -0,0 +1,3 @@
## Description
## Test Plan

34
web/apps/payments/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

View file

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"trailingComma": "es5",
"singleQuote": true,
"jsxBracketSameLine": true
}

View file

@ -0,0 +1,59 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with
[`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
## Notes
If you're running this to test out the payment flows end-to-end, please do a
`yarn build`, that will place the output within the `out` folder.
Then use any tool to serve this over HTTP. For example, `python3 -m http.server
3001` will serve this directory over port `3001`.
Aside that, these are the necessary configuration changes.
### Local configuration
Update the `.env.local` to point to the local museum instance, and to define the
necessary Stripe keys that can be fetched from [Stripe's developer
dashboard](https://dashboard.stripe.com).
Assuming that your local museum instance is running on `192.168.1.2:8080`, your
`.env.local` should look as follows.
```
NEXT_PUBLIC_ENTE_ENDPOINT = http://192.168.1.2:8080
NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY = stripe_publishable_key
```
### Museum
1. Install the [stripe-cli](https://docs.stripe.com/stripe-cli) and capture the
webhook signing secret.
2. Define this secret within your `musuem.yaml`
3. Update the `whitelisted-redirect-urls` so that it supports redirecting to this locally running project
Assuming that your local payments app is running on `192.168.1.2:3001`, your
`museum.yaml` should look as follows.
```yaml
stripe:
us:
key: stripe_dev_key
webhook-secret: stripe_dev_webhook_secret
whitelisted-redirect-urls: ["http://192.168.1.2:3001/frameRedirect"]
path:
success: ?status=success&session_id={CHECKOUT_SESSION_ID}
cancel: ?status=fail&reason=canceled
```

View file

@ -0,0 +1,44 @@
ente believes that working with security researchers across the globe is crucial to keeping our
users safe. If you believe you've found a security issue in our product or service, we encourage you to
notify us (security@ente.io). We welcome working with you to resolve the issue promptly. Thanks in advance!
# Disclosure Policy
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every
effort to quickly resolve the issue.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
- If you would like to encrypt your report, please use the PGP key with long ID
`E273695C0403F34F74171932DF6DDDE98EBD2394` (available in the public keyserver pool).
# In-scope
- Security issues in any current release of ente. This includes the web app, desktop app,
and mobile apps (iOS and Android). Product downloads are available at https://ente.io. Source
code is available at https://github.com/ente-io.
# Exclusions
The following bug classes are out-of scope:
- Bugs that are already reported on any of ente's issue trackers (https://github.com/ente-io),
or that we already know of. Note that some of our issue tracking is private.
- Issues in an upstream software dependency (ex: Flutter, Next.js etc) which are already reported to the upstream maintainer.
- Attacks requiring physical access to a user's device.
- Self-XSS
- Issues related to software or protocols not under ente's control
- Vulnerabilities in outdated versions of ente
- Missing security best practices that do not directly lead to a vulnerability
- Issues that do not have any impact on the general public
While researching, we'd like to ask you to refrain from:
- Denial of service
- Spamming
- Social engineering (including phishing) of ente staff or contractors
- Any physical attempts against ente property or data centers
Thank you for helping keep ente and our users safe!

View file

@ -0,0 +1,44 @@
// This file sets a custom webpack configuration to use your Next.js app
// with Sentry.
// https://nextjs.org/docs/api-reference/next.config.js/introduction
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
const { withSentryConfig } = require('@sentry/nextjs');
const cp = require('child_process');
const gitSha = cp.execSync('git rev-parse --short HEAD', {
cwd: __dirname,
encoding: 'utf8',
});
const moduleExports = {
// Your existing module.exports
output: 'export',
reactStrictMode: true,
env: {
SENTRY_RELEASE: gitSha,
},
sentry: {
hideSourceMaps: false,
},
};
const SentryWebpackPluginOptions = {
// Additional config options for the Sentry Webpack plugin. Keep in mind that
// the following options are set automatically, and overriding them is not
// recommended:
// release, url, org, project, authToken, configFile, stripPrefix,
// urlPrefix, include, ignore
release: gitSha,
silent: true, // Suppresses all logs
// Ignore sentry webpack errors
errorHandler: (err, invokeErr, compilation) => {
compilation.warnings.push('Sentry CLI Plugin: ' + err.message);
},
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options.
};
// Make sure adding Sentry options is the last code to run before exporting, to
// ensure that your source maps include changes from all other Webpack plugins
module.exports = withSentryConfig(moduleExports, SentryWebpackPluginOptions);

View file

@ -0,0 +1,31 @@
{
"name": "payments",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@sentry/nextjs": "^7.54.0",
"@stripe/stripe-js": "^1.17.0",
"axios": "^0.21.1",
"bootstrap": "4.6.0",
"next": "^14.1.4",
"react": "^18.2.0",
"react-bootstrap": "^1.6.1",
"react-dom": "^18.2.0",
"styled-components": "^5.3.0"
},
"devDependencies": {
"@types/node": "20.11.30",
"@types/react": "17.0.15",
"@types/styled-components": "^5.1.12",
"babel-plugin-styled-components": "^1.13.2",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.4",
"typescript": "^5.4.2"
}
}

View file

@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,27 @@
// This file configures the initialization of Sentry on the browser.
// The config you add here will be used whenever a page is visited.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN =
(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN) ??
'https://67447bc36684b1f7a18d79683b788f25@sentry.ente.io/6';
const TUNNEL_URL = 'https://sentry-reporter.ente.io';
const SENTRY_ENV = process.env.NEXT_PUBLIC_SENTRY_ENV ?? 'development';
Sentry.init({
dsn: SENTRY_DSN,
enabled: false,
environment: SENTRY_ENV,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
attachStacktrace: true,
autoSessionTracking: false,
tunnel: TUNNEL_URL,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View file

@ -0,0 +1,4 @@
defaults.url=https://sentry.ente.io/
defaults.org=ente
defaults.project=web-payments

View file

@ -0,0 +1,26 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
const SENTRY_DSN =
(process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN) ??
'https://208e398c28cd4c069c83d7c6e63adef6@sentry.ente.io/6';
const SENTRY_ENV = process.env.NEXT_PUBLIC_SENTRY_ENV ?? 'development';
Sentry.init({
dsn: SENTRY_DSN,
enabled: SENTRY_ENV !== 'development',
environment: SENTRY_ENV,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1.0,
release: process.env.SENTRY_RELEASE,
autoSessionTracking: false,
// ...
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
});

View file

@ -0,0 +1,9 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
`;

View file

@ -0,0 +1,10 @@
import React from 'react';
import { Spinner } from 'react-bootstrap';
export default function EnteSpinner(props: any) {
return (
<Spinner {...props} animation="border" variant="success" role="status">
<span className="sr-only">Loading...</span>
</Spinner>
);
}

View file

@ -0,0 +1,2 @@
export const DESKTOP_REDIRECT_URL = 'ente://app/gallery';
export const ENTE_WEBSITE_URL = 'https://ente.io';

View file

@ -0,0 +1,7 @@
import { Container } from 'components/Container';
import React from 'react';
import constants from 'utils/strings/constants';
export default function Home() {
return <Container>{constants.NOT_FOUND}</Container>;
}

View file

@ -0,0 +1,18 @@
import '../styles/globals.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import type { AppProps } from 'next/app';
import React from 'react';
import constants from 'utils/strings/constants';
import Head from 'next/head';
function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>{constants.TITLE}</title>
</Head>
<Component {...pageProps} />
</>
);
}
export default MyApp;

View file

@ -0,0 +1,20 @@
import { Container } from 'components/Container';
import EnteSpinner from 'components/EnteSpinner';
import { DESKTOP_REDIRECT_URL } from 'constants/common';
import { useRouter } from 'next/dist/client/router';
import React, { useEffect, useState } from 'react';
export default function DesktopRedirect() {
useEffect(() => {
const currentURL = new URL(window.location.href);
const desktopRedirectURL = new URL(DESKTOP_REDIRECT_URL);
desktopRedirectURL.search = currentURL.search;
window.location.href = desktopRedirectURL.href;
}, []);
return (
<Container>
<EnteSpinner animation="border" />
</Container>
);
}

View file

@ -0,0 +1,39 @@
import { Container } from 'components/Container';
import EnteSpinner from 'components/EnteSpinner';
import { ENTE_WEBSITE_URL } from 'constants/common';
import React, { useEffect, useState } from 'react';
import { parseAndHandleRequest } from 'services/billingService';
import { CUSTOM_ERROR } from 'utils/error';
import constants from 'utils/strings/constants';
export default function Home() {
const [errorMessageView, setErrorMessageView] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
async function main() {
try {
setLoading(true);
await parseAndHandleRequest();
} catch (e: any) {
if (
e.message === CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS
) {
window.location.href = ENTE_WEBSITE_URL;
} else {
setErrorMessageView(true);
}
}
}
main();
}, []);
return (
<Container>
{errorMessageView ? (
<div>{constants.SOMETHING_WENT_WRONG}</div>
) : (
loading && <EnteSpinner animation="border" />
)}
</Container>
);
}

View file

@ -0,0 +1,175 @@
import axios, { AxiosRequestConfig } from 'axios';
interface IHTTPHeaders {
[headerKey: string]: any;
}
interface IQueryPrams {
[paramName: string]: any;
}
/**
* Service to manage all HTTP calls.
*/
class HTTPService {
constructor() {
axios.interceptors.response.use(
(response) => Promise.resolve(response),
(err) => {
if (!err.response) {
return Promise.reject(err);
}
const { response } = err;
return Promise.reject(response);
},
);
}
/**
* header object to be append to all api calls.
*/
private headers: IHTTPHeaders = {
'content-type': 'application/json',
};
/**
* Sets the headers to the given object.
*/
public setHeaders(headers: IHTTPHeaders) {
this.headers = headers;
}
/**
* Adds a header to list of headers.
*/
public appendHeader(key: string, value: string) {
this.headers = {
...this.headers,
[key]: value,
};
}
/**
* Removes the given header.
*/
public removeHeader(key: string) {
this.headers[key] = undefined;
}
/**
* Returns axios interceptors.
*/
// eslint-disable-next-line class-methods-use-this
public getInterceptors() {
return axios.interceptors;
}
/**
* Generic HTTP request.
* This is done so that developer can use any functionality
* provided by axios. Here, only the set headers are spread
* over what was sent in config.
*/
public async request(config: AxiosRequestConfig, customConfig?: any) {
// eslint-disable-next-line no-param-reassign
config.headers = {
...this.headers,
...config.headers,
};
if (customConfig?.cancel) {
config.cancelToken=new axios.CancelToken((c)=> (customConfig.cancel.exec=c));
}
return await axios({ ...config, ...customConfig });
}
/**
* Get request.
*/
public get(
url: string,
params?: IQueryPrams,
headers?: IHTTPHeaders,
customConfig?: any,
) {
return this.request(
{
headers,
method: 'GET',
params,
url,
},
customConfig,
);
}
/**
* Post request
*/
public post(
url: string,
data?: any,
params?: IQueryPrams,
headers?: IHTTPHeaders,
customConfig?: any,
) {
return this.request(
{
data,
headers,
method: 'POST',
params,
url,
},
customConfig,
);
}
/**
* Put request
*/
public put(
url: string,
data: any,
params?: IQueryPrams,
headers?: IHTTPHeaders,
customConfig?: any,
) {
return this.request(
{
data,
headers,
method: 'PUT',
params,
url,
},
customConfig,
);
}
/**
* Delete request
*/
public delete(
url: string,
data: any,
params?: IQueryPrams,
headers?: IHTTPHeaders,
customConfig?: any,
) {
return this.request(
{
data,
headers,
method: 'DELETE',
params,
url,
},
customConfig,
);
}
}
// Creates a Singleton Service.
// This will help me maintain common headers / functionality
// at a central place.
export default new HTTPService();

View file

@ -0,0 +1,281 @@
import { loadStripe, Stripe } from '@stripe/stripe-js';
import { CUSTOM_ERROR } from 'utils/error';
import { logError } from 'utils/sentry';
import HTTPService from './HTTPService';
const getStripePublishableKey = (stripeAccount: StripeAccountCountry) => {
if (stripeAccount === StripeAccountCountry.STRIPE_IN) {
return (
process.env.NEXT_PUBLIC_STRIPE_IN_PUBLISHABLE_KEY ??
'pk_live_51HAhqDK59oeucIMOiTI6MDDM2UWUbCAJXJCGsvjJhiO8nYJz38rQq5T4iyQLDMKxqEDUfU5Hopuj4U5U4dff23oT00fHvZeodC'
);
} else if (stripeAccount === StripeAccountCountry.STRIPE_US) {
return (
process.env.NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY ??
'pk_live_51LZ9P4G1ITnQlpAnrP6pcS7NiuJo3SnJ7gibjJlMRatkrd2EY1zlMVTVQG5RkSpLPbsHQzFfnEtgHnk1PiylIFkk00tC0LWXwi'
);
} else {
throw Error('stripe account not found');
}
};
const getEndpoint = () => {
const endPoint =
process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? 'https://api.ente.io';
return endPoint;
};
enum PAYMENT_INTENT_STATUS {
SUCCESS = 'success',
REQUIRE_ACTION = 'requires_action',
REQUIRE_PAYMENT_METHOD = 'requires_payment_method',
}
enum FAILURE_REASON {
// Unable to authenticate card or 3DS
// User should be showing button for fixing card via customer portal
AUTHENTICATION_FAILED = 'authentication_failed',
// Card declined result in this error. Show button to the customer portal.
REQUIRE_PAYMENT_METHOD = 'requires_payment_method',
STRIPE_ERROR = 'stripe_error',
CANCELED = 'canceled',
SERVER_ERROR = 'server_error',
}
enum STRIPE_ERROR_TYPE {
CARD_ERROR = 'card_error',
AUTHENTICATION_ERROR = 'authentication_error',
}
enum STRIPE_ERROR_CODE {
AUTHENTICATION_ERROR = 'payment_intent_authentication_failure',
}
enum RESPONSE_STATUS {
success = 'success',
fail = 'fail',
}
enum PaymentActionType {
Buy = 'buy',
Update = 'update',
}
enum StripeAccountCountry {
STRIPE_IN = 'IN',
STRIPE_US = 'US',
}
interface SubscriptionUpdateResponse {
result: {
status: PAYMENT_INTENT_STATUS;
clientSecret: string;
};
}
export async function parseAndHandleRequest() {
try {
const urlParams = new URLSearchParams(window.location.search);
const productID = urlParams.get('productID');
const paymentToken = urlParams.get('paymentToken');
const action = urlParams.get('action');
const redirectURL = urlParams.get('redirectURL');
if (!action && !paymentToken && !productID && !redirectURL) {
throw Error(CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS);
} else if (!action || !paymentToken || !productID || !redirectURL) {
throw Error(CUSTOM_ERROR.MISSING_REQUIRED_QUERY_PARAM);
}
switch (action) {
case PaymentActionType.Buy:
await buyPaidSubscription(productID, paymentToken, redirectURL);
break;
case PaymentActionType.Update:
await updateSubscription(productID, paymentToken, redirectURL);
break;
default:
throw Error(CUSTOM_ERROR.INVALID_ACTION);
}
} catch (e: any) {
console.error("Error: ", JSON.stringify(e)) ;
if (e.message !== CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS) {
logError(e);
}
throw e;
}
}
async function getUserStripeAccountCountry(
paymentToken: string,
): Promise<{ stripeAccountCountry: StripeAccountCountry }> {
const response = await HTTPService.get(
`${getEndpoint()}/billing/stripe-account-country`,
undefined,
{
'X-Auth-Token': paymentToken,
},
);
return response.data;
}
async function getStripe(
redirectURL: string,
stripeAccount: StripeAccountCountry,
) {
try {
const publishableKey = getStripePublishableKey(stripeAccount);
const stripe = await loadStripe(publishableKey);
if (!stripe) {
throw Error('stripe load failed');
}
return stripe;
} catch (e) {
logError(e, 'stripe load failed');
redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.STRIPE_ERROR,
);
throw e;
}
}
export async function buyPaidSubscription(
productID: string,
paymentToken: string,
redirectURL: string,
) {
try {
const { stripeAccountCountry } = await getUserStripeAccountCountry(
paymentToken,
);
const stripe = await getStripe(redirectURL, stripeAccountCountry);
const { sessionID } = await createCheckoutSession(
productID,
paymentToken,
redirectURL,
);
await stripe.redirectToCheckout({
sessionId: sessionID,
});
} catch (e) {
logError(e, 'subscription purchase failed');
redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.SERVER_ERROR,
);
throw e;
}
}
async function createCheckoutSession(
productID: string,
paymentToken: string,
redirectURL: string,
): Promise<{ sessionID: string }> {
const response = await HTTPService.get(
`${getEndpoint()}/billing/stripe/checkout-session`,
{
productID,
redirectURL,
},
{
'X-Auth-Token': paymentToken,
},
);
return response.data;
}
export async function updateSubscription(
productID: string,
paymentToken: string,
redirectURL: string,
) {
try {
const { stripeAccountCountry } = await getUserStripeAccountCountry(
paymentToken,
);
const stripe = await getStripe(redirectURL, stripeAccountCountry);
const { result } = await subscriptionUpdateRequest(
paymentToken,
productID,
);
switch (result.status) {
case PAYMENT_INTENT_STATUS.SUCCESS:
// subscription updated successfully
// no-op required
return redirectToApp(redirectURL, RESPONSE_STATUS.success);
case PAYMENT_INTENT_STATUS.REQUIRE_PAYMENT_METHOD:
return redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
);
case PAYMENT_INTENT_STATUS.REQUIRE_ACTION: {
const { error } = await stripe.confirmCardPayment(
result.clientSecret,
);
if (error) {
logError(
error,
`${error.message} - subscription update failed`,
);
if (error.type === STRIPE_ERROR_TYPE.CARD_ERROR) {
return redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
);
} else if (
error.type === STRIPE_ERROR_TYPE.AUTHENTICATION_ERROR ||
error.code === STRIPE_ERROR_CODE.AUTHENTICATION_ERROR
) {
return redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.AUTHENTICATION_FAILED,
);
} else {
return redirectToApp(redirectURL, RESPONSE_STATUS.fail);
}
} else {
return redirectToApp(redirectURL, RESPONSE_STATUS.success);
}
}
}
} catch (e) {
logError(e, 'subscription update failed');
redirectToApp(
redirectURL,
RESPONSE_STATUS.fail,
FAILURE_REASON.SERVER_ERROR,
);
throw e;
}
}
async function subscriptionUpdateRequest(
paymentToken: string,
productID: string,
): Promise<SubscriptionUpdateResponse> {
const response = await HTTPService.post(
`${getEndpoint()}/billing/stripe/update-subscription`,
{
productID,
},
undefined,
{
'X-Auth-Token': paymentToken,
},
);
return response.data;
}
function redirectToApp(redirectURL: string, status: string, reason?: string) {
let completePath = `${redirectURL}?status=${status}`;
if (reason) {
completePath = `${completePath}&reason=${reason}`;
}
window.location.href = completePath;
}

View file

@ -0,0 +1,41 @@
/* ubuntu-regular - latin */
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
src: local(''), url('/fonts/ubuntu-v15-latin-regular.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('/fonts/ubuntu-v15-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* ubuntu-700 - latin */
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
src: local(''), url('/fonts/ubuntu-v15-latin-700.woff2') format('woff2'),
/* Chrome 26+, Opera 23+, Firefox 39+ */
url('/fonts/ubuntu-v15-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
html,
body {
padding: 0;
margin: 0;
font-family: Arial, Helvetica, sans-serif;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
background-color: #191919 !important;
color: #aaa !important;
font-family: Ubuntu, Arial, sans-serif !important;
}
:is(h1, h2, h3, h4, h5, h6) {
color: #d7d7d7;
}
#__next {
flex: 1;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,3 @@
export function runningInBrowser() {
return typeof window !== 'undefined';
}

View file

@ -0,0 +1,5 @@
export const CUSTOM_ERROR = {
DIRECT_OPEN_WITH_NO_QUERY_PARAMS: 'direct open with no query params',
MISSING_REQUIRED_QUERY_PARAM: 'missing required query param',
INVALID_ACTION: 'invalid action',
};

View file

@ -0,0 +1,28 @@
import { logError } from 'utils/sentry';
export enum LS_KEYS {
AnonymizeUserID = 'anonymizedUserID',
}
export const getData = (key: LS_KEYS) => {
try {
if (
typeof localStorage === 'undefined' ||
typeof key === 'undefined' ||
typeof localStorage.getItem(key) === 'undefined'
) {
return null;
}
const data = localStorage.getItem(key);
return data && JSON.parse(data);
} catch (e) {
logError(e, 'Failed to Parse JSON');
}
};
export const setData = (key: LS_KEYS, value: object) => {
if (typeof localStorage === 'undefined') {
return null;
}
localStorage.setItem(key, JSON.stringify(value));
};

View file

@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';
import { getUserAnonymizedID } from 'utils/user';
export const logError = (e: any, msg?: string) => {
Sentry.captureException(e, {
level: "info",
user: { id: getUserAnonymizedID() },
contexts: {
context: {
message: msg,
},
},
});
};

View file

@ -0,0 +1,4 @@
import { getConstantValue } from './vernacularStrings';
const constants = getConstantValue();
export default constants;

View file

@ -0,0 +1,6 @@
const englishConstants = {
TITLE: 'Payments | ente.io',
SOMETHING_WENT_WRONG: 'Oops, something went wrong.',
NOT_FOUND: '404 | This page could not be found.',
};
export default englishConstants;

View file

@ -0,0 +1,87 @@
import { runningInBrowser } from 'utils/common';
import englishConstants from './englishConstants';
/** Enums of supported locale */
export enum locale {
en = 'en',
hi = 'hi',
}
/**
* Defines a template with placeholders which can then be
* substituted at run time. Enabling the developer to create
* different template for different locale and populate them
* at run time.
*
* @param strings
* @param keys
* @returns string
*/
export function template(
strings: TemplateStringsArray,
...keys: string[] | number[]
) {
return (...values: any[]) => {
const dict = values[values.length - 1] || {};
const result = [strings[0]];
keys.forEach((key, i) => {
const value = typeof key === 'number' ? values[key] : dict[key];
result.push(value, strings[i + 1]);
});
return result.join('');
};
}
/** Type for vernacular string constants */
export type VernacularConstants<T> = {
[locale.en]: T;
[locale.hi]?: {
[x in keyof T]?: string;
};
};
/**
* Returns a valid locale from string and defaults
* to English.
*
* @param lang
*/
export const getLocale = (lang: string) => {
switch (lang) {
case locale.hi:
return locale.hi;
default:
return locale.en;
}
};
/**
* Global constants
*/
const globalConstants: VernacularConstants<typeof englishConstants> = {
en: englishConstants,
};
/**
* Function to extend global constants with local constants
* @param localConstants
*/
export function getConstantValue<T>(localConstants?: VernacularConstants<T>) {
const searchParam = runningInBrowser() ? window.location.search : '';
const query = new URLSearchParams(searchParam);
const currLocale = getLocale(query.get('lang') ?? 'en');
if (currLocale !== 'en') {
return {
...globalConstants.en,
...localConstants?.en,
...globalConstants[currLocale],
...localConstants?.[currLocale],
};
}
return {
...globalConstants[currLocale],
...localConstants?.[currLocale],
};
}

View file

@ -0,0 +1,23 @@
import { getData, LS_KEYS, setData } from 'utils/localStorage';
export function makeID(length: number) {
let result = '';
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * charactersLength),
);
}
return result;
}
export function getUserAnonymizedID() {
let anonymizeUserID = getData(LS_KEYS.AnonymizeUserID)?.id;
if (!anonymizeUserID) {
anonymizeUserID = makeID(6);
setData(LS_KEYS.AnonymizeUserID, { id: anonymizeUserID });
}
return anonymizeUserID;
}

View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": "./src",
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

3364
web/apps/payments/yarn.lock Normal file

File diff suppressed because it is too large Load diff