Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IDM-209] - feat: stripe checkout #587

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
30cc325
feat(project): add stripe checkout
kiremitrov123 Jul 25, 2024
2a1ee0e
feat(project): add types in common
kiremitrov123 Jul 25, 2024
54713ec
feat(project): add tests for stripe checkout
kiremitrov123 Jul 26, 2024
e918525
feat(project): add more tests for checkout
kiremitrov123 Jul 28, 2024
0557a39
chore: stripe checkout params
kiremitrov123 Jul 28, 2024
2ba1416
feat(project): add test mocks class
kiremitrov123 Jul 29, 2024
5c2ae2c
feat(project): add metadata in stripe checkout
kiremitrov123 Jul 29, 2024
c4231be
feat(project): use external providers
kiremitrov123 Jul 30, 2024
6724a9f
Merge branch 'IDM-169/stripe-products' of https://github.com/jwplayer…
kiremitrov123 Jul 31, 2024
7489d07
feat(project): add accounts service usage
kiremitrov123 Jul 31, 2024
75a4989
Merge branch 'IDM-169/stripe-products' of https://github.com/jwplayer…
kiremitrov123 Jul 31, 2024
73d7945
feat(project): refactor app to use vite, express and vitest for testing
kiremitrov123 Aug 22, 2024
264a1f6
Merge branch 'IDM-169/stripe-products' of https://github.com/jwplayer…
kiremitrov123 Aug 26, 2024
2797ec3
chore: update checkout params
kiremitrov123 Aug 26, 2024
27f115b
feat(project): add site_id in the route
kiremitrov123 Aug 26, 2024
6551e2d
chore: update branch
kiremitrov123 Sep 3, 2024
f36f1ca
feat(project): remove service level error handling
kiremitrov123 Sep 3, 2024
cd9adc2
feat(project): simplify checkout controller to use shared service iml…
kiremitrov123 Sep 13, 2024
dfa24f0
chore: update readme
kiremitrov123 Sep 13, 2024
29199eb
chore: update comments on stripe payment service
kiremitrov123 Sep 13, 2024
a57359b
chore: remove unused type
kiremitrov123 Sep 13, 2024
3c4220f
feat(project): update test fixtures
kiremitrov123 Sep 16, 2024
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
7 changes: 7 additions & 0 deletions packages/common/types/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ export type Product = {
// Array of price objects.
prices: Price[];
};

// General checkout parameters type. Can be extended by specific payment providers, e.g. Stripe
export type CheckoutParams = {
price_id: string;
success_url: string;
cancel_url: string;
};
11 changes: 4 additions & 7 deletions packages/common/types/stripe.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
export type StripeCheckoutRequestParams = {
access_plan_id: string;
price_id: string;
redirect_url: string;
};
import type { CheckoutParams } from './payment';

export type StripeCheckoutResponse = {
url: string;
// Extend the CheckoutParams type with Stripe-specific fields
export type StripeCheckoutParams = CheckoutParams & {
mode: 'payment' | 'subscription';
};
21 changes: 21 additions & 0 deletions platforms/access-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,27 @@ You can also copy and paste the contents of `.env.example` into `.env.local` and
]
```

#### URL: `/v2/sites/{site_id}/checkout`

- **Method:** POST
- **Authorization:** Valid SIMS token
- **Summary:** Creates Payment Checkout Session URL where the viewer will be redirected to complete the payment.
- **Request:**
```json
{
"price_id": "string", // id of the price that is about to be paid
"mode": "string", // subscription (recurring) | payment (one time purchases)
"success_url": "string", // redirect after successful payment
"cancel_url": "string" // redirect after cancel / invalid payment
}
```
- **Response:**
```json
{
"url": "string" // url where the viewer will be redirected to complete the payment.
}
```

## Developer guidelines

- Read the workspace guidelines here [../../docs/developer-guidelines.md](../../docs/developer-guidelines.md).
Expand Down
43 changes: 43 additions & 0 deletions platforms/access-bridge/src/controllers/checkout-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response, NextFunction } from 'express';

import { ErrorDefinitions, sendErrors } from '../errors.js';
import { IdentityService } from '../services/identity-service.js';
import { PaymentService } from '../services/payment-service.js';
import { StripePaymentService } from '../services/stripe-payment-service.js';

/**
* Controller class responsible for handling payment checkout session URLs, where the viewers can complete the payment.
*/
export class CheckoutController {
private readonly identityService: IdentityService;
private readonly paymentService: PaymentService;

constructor() {
this.identityService = new IdentityService();
this.paymentService = new StripePaymentService();
}

/**
* Service handler for initiating a Payment Checkout session based on the provided checkout params.
* @returns A Promise that resolves with a response containing the URL for the Payment Provider Checkout session.
*/
async initiateCheckout(req: Request, res: Response, next: NextFunction): Promise<void> {
const authorization = req.headers['authorization'];
if (!authorization) {
sendErrors(res, ErrorDefinitions.UnauthorizedError.create());
return;
}

const checkoutParams = req.body;
const validationError = this.paymentService.validateCheckoutParams(checkoutParams);
if (validationError) {
sendErrors(res, ErrorDefinitions.ParameterMissingError.create({ parameterName: validationError }));
return;
}

const viewer = await this.identityService.getAccount({ authorization });
const checkoutSessionUrl = await this.paymentService.createCheckoutSessionUrl(viewer, checkoutParams);

res.json({ url: checkoutSessionUrl });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { PlansService } from '../services/plans-service.js';
import { StripePaymentService } from '../services/stripe-payment-service.js';
import { PaymentService } from '../services/payment-service.js';
/**
* Controller class responsible for handling Stripe-related services.
* Controller class responsible for handling AC plans and Stripe products.
*/
export class ProductsController {
private readonly plansService: PlansService;
Expand Down
12 changes: 6 additions & 6 deletions platforms/access-bridge/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,13 @@ export function handleJWError(error: JWErrorResponse): AccessBridgeError {
// Utility function to handle Stripe errors
export function handleStripeError(error: Stripe.errors.StripeError): AccessBridgeError {
if (error.type === 'StripeInvalidRequestError') {
return ErrorDefinitions.BadRequestError.create({ description: error.message });
throw ErrorDefinitions.BadRequestError.create({ description: error.message });
} else if (error.type === 'StripeAuthenticationError') {
return ErrorDefinitions.UnauthorizedError.create({ description: error.message });
throw ErrorDefinitions.UnauthorizedError.create({ description: error.message });
} else if (error.type === 'StripePermissionError') {
return ErrorDefinitions.ForbiddenError.create({ description: error.message });
} else {
// Fallback to a generic InternalError for unexpected Stripe errors
return ErrorDefinitions.InternalError.create({ description: error.message });
throw ErrorDefinitions.ForbiddenError.create({ description: error.message });
}

// Fallback to a generic BadRequestError for unexpected Stripe errors
throw ErrorDefinitions.BadRequestError.create({ description: error.message });
}
3 changes: 3 additions & 0 deletions platforms/access-bridge/src/pipeline/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ import { Express, Request, Response, NextFunction } from 'express';

import { AccessController } from '../controllers/access-controller.js';
import { ProductsController } from '../controllers/products-controller.js';
import { CheckoutController } from '../controllers/checkout-controller.js';

import { Middleware } from './middleware.js';

const middleware = new Middleware();
const accessController = new AccessController();
const productsController = new ProductsController();
const checkoutController = new CheckoutController();

export function initializeRoutes(app: Express) {
// Register routes with their respective controller methods
addRoute(app, 'put', '/v2/sites/:site_id/access/generate', accessController.generatePassport.bind(accessController));
addRoute(app, 'put', '/v2/sites/:site_id/access/refresh', accessController.refreshPassport.bind(accessController));
addRoute(app, 'get', '/v2/sites/:site_id/products', productsController.getProducts.bind(productsController));
addRoute(app, 'post', '/v2/sites/:site_id/checkout', checkoutController.initiateCheckout.bind(checkoutController));
}

// Adds a route to the Express application with the specified HTTP method, path, and handler.
Expand Down
28 changes: 24 additions & 4 deletions platforms/access-bridge/src/services/payment-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { Product } from '@jwp/ott-common/types/payment.js';
import type { CheckoutParams, Product } from '@jwp/ott-common/types/payment.js';

import { Viewer } from './identity-service';
/**
* PaymentService interface defines the contract for payment service implementations. *
* Any class implementing this should handle products and prices from a specific payment provider (e.g., Stripe).
* PaymentService interface defines the contract for payment service implementations.
* Any class implementing this should handle products, prices, checkout and payment,
* from a specific payment provider (e.g., Stripe, Google, Apple).
*/
export interface PaymentService {
export interface PaymentService<T extends CheckoutParams = CheckoutParams> {
/**
* Retrieves products with prices based on the provided product IDs.
* The implementation should interact with the payment provider's API to fetch products and prices details.
Expand All @@ -12,4 +15,21 @@ export interface PaymentService {
* @returns A Promise that resolves to an array of products, each containing associated price details.
*/
getProductsWithPrices(productIds: string[]): Promise<Product[]>;

/**
* Creates a checkout session based on the provided viewer and checkout parameters.
* This method should be implemented by each provider's payment service.
*
* @param viewer The viewer making the payment (e.g., their email and ID).
* @param params The generic checkout parameters that will be customized for each payment provider.
* @returns A Promise resolving to a checkout session URL depending on the provider.
*/
createCheckoutSessionUrl(viewer: Viewer, params: T): Promise<string | null>;

/**
* Validates the provided checkout parameters based on the specific provider's requirements.
* @param params - The checkout parameters to validate.
* @returns An error string if validation fails, or null if validation succeeds.
*/
validateCheckoutParams(params: T): string | null;
}
55 changes: 55 additions & 0 deletions platforms/access-bridge/src/services/stripe-payment-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Stripe from 'stripe';
import { Product, Price } from '@jwp/ott-common/types/payment.js';
import { StripeCheckoutParams } from '@jwp/ott-common/types/stripe.js';

import { STRIPE_SECRET } from '../app-config.js';

import { PaymentService } from './payment-service.js';
import { Viewer } from './identity-service.js';

/**
* Service class responsible for interacting with the Stripe API to fetch products.
Expand Down Expand Up @@ -61,6 +63,59 @@ export class StripePaymentService implements PaymentService {
return productsWithPrices.filter((product) => product !== null) as Product[];
}

/**
* Creates a Stripe Checkout session URL, where the viewer will be redirected to complete the payment.
* @param viewer Email address and viewer id from the auth token used for creating the checkout session.
* @param params Stripe checkout params to use for creating the checkout session.
* @returns A Promise resolving to a Stripe Checkout Session URL for the checkout page.
*/
async createCheckoutSessionUrl(viewer: Viewer, params: StripeCheckoutParams): Promise<string | null> {
const sessionParams: Stripe.Checkout.SessionCreateParams = {
payment_method_types: ['card'],
line_items: [
{
price: params.price_id,
quantity: 1,
},
],
metadata: {
// This is very important as it's our only way of connecting the payment back to the viewer
viewer_id: viewer.id,
},
customer_email: viewer.email,
mode: params.mode,
success_url: params.success_url,
cancel_url: params.cancel_url,

// Conditionally include `subscription_data` only if mode is `subscription`
...(params.mode === 'subscription' && {
subscription_data: {
metadata: {
// This is very important as it's our only way of connecting the payment back to the viewer
viewer_id: viewer.id,
},
},
}),
};

const checkoutSession = await this.stripe.checkout.sessions.create(sessionParams);
return checkoutSession.url;
}

/**
* Validates the provided checkout parameters.
* Checks for the presence of required fields: 'price_id', 'mode', 'success_url', and 'cancel_url'.
* If any required parameter is missing, returns an error message; otherwise, returns null.
* @param params - The checkout parameters to validate.
* @returns A string containing the name of the missing parameter if validation fails,
* or null if all required parameters are present.
*/
validateCheckoutParams(params: StripeCheckoutParams): string | null {
const requiredParams: (keyof StripeCheckoutParams)[] = ['price_id', 'mode', 'success_url', 'cancel_url'];
const missingParam = requiredParams.find((param) => !params[param]);
return missingParam ? `Missing required parameter: ${missingParam}` : null;
}

/**
* Maps the Stripe product to our custom Product type.
* @param product The Stripe product object.
Expand Down
5 changes: 5 additions & 0 deletions platforms/access-bridge/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const ENDPOINTS = {
GENERATE_PASSPORT: '/v2/sites/:site_id/access/generate',
REFRESH_PASSPORT: '/v2/sites/:site_id/access/refresh',
PRODUCTS: '/v2/sites/:site_id/products',
CHECKOUT: '/v2/sites/:site_id/checkout',
};

// mock data for access tokens
Expand Down Expand Up @@ -122,6 +123,7 @@ export const SITE_ID = {
export const AUTHORIZATION = {
VALID: 'Bearer valid-authorization',
INVALID: 'Bearer invalid-authorization',
MISSING: '',
};

// Store price mock
Expand Down Expand Up @@ -189,3 +191,6 @@ export const STRIPE_ERRORS = [
statusCode: 400,
},
];

// mock of stripe session url
export const STRIPE_SESSION_URL = 'https://example.com';
Loading
Loading