Skip to content

Online shop/membership #292

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

Open
wants to merge 26 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ea98ef4
added membership controller
adisa39 Jul 14, 2025
b5de049
added membership model
adisa39 Jul 14, 2025
4d20454
added membership enum types
adisa39 Jul 14, 2025
fadbd27
added membership service
adisa39 Jul 14, 2025
c650751
updated types to include membership
adisa39 Jul 14, 2025
cb490d3
added membership routes
adisa39 Jul 14, 2025
b65355a
added isMembershipFound middleware
adisa39 Jul 14, 2025
36a5ce5
added membership API endpoint
adisa39 Jul 14, 2025
d71cf04
updated membership schema to include IMembership type
adisa39 Jul 14, 2025
dd0eb83
updated membership tiers to use json file for scalability
adisa39 Jul 15, 2025
962f01a
updated membership json and membershipClassType to dynamically includ…
adisa39 Jul 15, 2025
4ca33f6
included logics to fetch pioneer membership
adisa39 Jul 15, 2025
512a3fc
fix API URL bug
adisa39 Jul 16, 2025
5803d17
reverted the use of json to store membershipt tiers
adisa39 Jul 16, 2025
452b72a
revamped membership class to use centralized membership object for sc…
adisa39 Jul 17, 2025
f65236a
extracted membership decision logics functions in helpers
adisa39 Jul 17, 2025
11f3d8b
rename membership API endpoint
adisa39 Jul 17, 2025
1e7ccbb
Inplement membership payment
adisa39 Jul 17, 2025
db8e0c3
updated getUserMembership function to create new membership for users…
adisa39 Jul 17, 2025
bb69c60
updated user authentication to fetch/create user membership
adisa39 Jul 17, 2025
0b8f372
removed multiple user verification during payment
adisa39 Jul 18, 2025
bff9ee4
Temp rename membership.ts to Membership_.ts to fix broken serverless …
swoocn Jul 19, 2025
c421e53
Perm rename Membership_.ts back to Membership.ts to stabilize serverl…
swoocn Jul 19, 2025
fd03105
Attempt to resolve unit test failures; Temp change Membership to Memb…
swoocn Jul 20, 2025
c14b54b
Perm change Membership_ back to Membership; remove stray middleware; …
swoocn Jul 20, 2025
2a64361
Attempt to resolve remaining unit test failiures.
swoocn Jul 21, 2025
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
78 changes: 78 additions & 0 deletions src/controllers/membershipController.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your version is clean, but I noticed updateOrRenewMembership lacks input validation to guard against malformed payloads. Do you handle this somewhere else? Consider adding:

if (
!membership_class ||
!Object.values(MembershipClassType).includes(membership_class) ||
typeof membership_duration !== 'number' ||
typeof mappi_allowance !== 'number'
) {
return res.status(400).json({ error: 'Invalid request payload' });
}

We might consider using Zod or similar down the road.

Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Request, Response } from "express";
import * as membershipService from "../services/membership.service";
import logger from "../config/loggingConfig";
import { IUser } from "../types"

export const fetchMembershipList = async (req: Request, res: Response) => {
try {
const membershipList = await membershipService.buildMembershipList();

if (!membershipList) {
logger.warn(`No membership list found.`);
return res.status(404).json({ message: "Membership list not found" });
}

logger.info(`Fetched membership list`);
return res.status(200).json(membershipList);

} catch (error) {
logger.error(`Error getting membership list: `, error);
return res.status(500).json({ message: 'An error occurred while getting single membership list; please try again later' });
}
};

export const fetchUserMembership = async (req: Request, res: Response) => {
const authUser = req.currentUser as IUser;

try {
const currentMembership = await membershipService.getUserMembership(authUser);

if (!currentMembership) {
logger.warn(`Membership with ID ${authUser.pi_uid} not found.`);
return res.status(404).json({ message: "Membership not found" });
}

logger.info(`Fetched membership with ID ${authUser.pi_uid}`);
return res.status(200).json(currentMembership);
} catch (error) {
logger.error(`Error getting membership ID ${authUser.pi_uid}:`, error);
return res.status(500).json({ message: 'An error occurred while getting single membership; please try again later' });
}
};


// Controller to fetch a single membership by its ID
export const getSingleMembership = async (req: Request, res: Response) => {
const { membership_id } = req.params;

try {
const membership = await membershipService.getSingleMembershipById(membership_id);

if (!membership) {
logger.warn(`Membership with ID ${membership_id} not found.`);
return res.status(404).json({ message: "Membership not found" });
}

logger.info(`Fetched membership with ID ${membership_id}`);
return res.status(200).json(membership);
} catch (error) {
logger.error(`Error getting membership ID ${membership_id}:`, error);
return res.status(500).json({ message: 'An error occurred while getting single membership; please try again later' });
}
};

export const updateOrRenewMembership = async (req: Request, res: Response) => {
try {
const { membership_class } = req.body;
const authUser = req.currentUser

if (!authUser) return res.status(401).json({ error: "Unauthorized - user not authenticated "});

const updated = await membershipService.updateOrRenewMembership(authUser.pi_uid, membership_class);

return res.status(200).json(updated);
} catch (error: any) {
logger.error("Failed to update or renew membership:", error);
return res.status(400).json({ error: error.message });
}
};
2 changes: 1 addition & 1 deletion src/controllers/orderController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const createOrder = async (req: Request, res: Response) => {
try {
// Ensure no payment ID is attached
const sanitizedOrderData = { ...orderData, paymentId: null };
const order = await orderService.createOrder(sanitizedOrderData, orderItems, buyer);
const order = await orderService.createOrder(sanitizedOrderData, orderItems, buyer.pi_uid);
if (!order) {
logger.error(`Failed to create order with provided data: ${JSON.stringify(sanitizedOrderData)}`);
return res.status(400).json({ message: "Invalid order data" });
Expand Down
6 changes: 2 additions & 4 deletions src/controllers/paymentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
processPaymentCompletion,
processPaymentError
} from "../helpers/payment";
import { IUser } from "../types";

export const onIncompletePaymentFound = async (req: Request, res: Response) => {
const { payment } = req.body;

try {
const processedPayment = await processIncompletePayment(payment);
return res.status(200).json(processedPayment);
Expand All @@ -24,10 +24,9 @@ export const onIncompletePaymentFound = async (req: Request, res: Response) => {
};

export const onPaymentApproval = async (req: Request, res: Response) => {
const currentUser = req.currentUser as IUser;
const { paymentId } = req.body;
try {
const approvedPayment = await processPaymentApproval(paymentId, currentUser);
const approvedPayment = await processPaymentApproval(paymentId);
return res.status(200).json(approvedPayment);
} catch (error) {
logger.error(`Failed to approve Pi payment for paymentID ${ paymentId }:`, error);
Expand Down Expand Up @@ -68,7 +67,6 @@ export const onPaymentCancellation = async (req: Request, res: Response) => {

export const onPaymentError = async (req: Request, res: Response) => {
const { paymentDTO, error } = req.body;

logger.error(`Received payment error callback from Pi:`, error);

if (!paymentDTO) {
Expand Down
15 changes: 9 additions & 6 deletions src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@ import * as userService from "../services/user.service";
import { IUser } from "../types";

import logger from '../config/loggingConfig';
import { getUserMembership } from "../services/membership.service";

export const authenticateUser = async (req: Request, res: Response) => {
const auth = req.body;

try {
const user = await userService.authenticate(auth.user);
const token = jwtHelper.generateUserToken(user);
const result = await userService.authenticate(auth.user);
const token = jwtHelper.generateUserToken(result.user);
const expiresDate = new Date(Date.now() + 1 * 24 * 60 * 60 * 1000); // 1 day

logger.info(`User authenticated: ${user.pi_uid}`);
logger.info(`User authenticated: ${result.user.pi_uid}`);

return res.cookie("token", token, {httpOnly: true, expires: expiresDate, secure: true, priority: "high", sameSite: "lax"}).status(200).json({
user,
user: result.user,
token,
membership_class: result.membership_class
});
} catch (error) {
logger.error('Failed to authenticate user:', error);
Expand All @@ -28,9 +30,10 @@ export const authenticateUser = async (req: Request, res: Response) => {

export const autoLoginUser = async(req: Request, res: Response) => {
try {
const currentUser = req.currentUser;
const currentUser = req.currentUser as IUser;
const membership = await getUserMembership(currentUser);
logger.info(`Auto-login successful for user: ${currentUser?.pi_uid || "NULL"}`);
res.status(200).json(currentUser);
res.status(200).json({user: currentUser, membership_class: membership.membership_class});
} catch (error) {
logger.error(`Failed to auto-login user for userID ${ req.currentUser?.pi_uid }:`, error);
return res.status(500).json({ message: 'An error occurred while auto-logging the user; please try again later' });
Expand Down
27 changes: 25 additions & 2 deletions src/helpers/membership.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logger from "../config/loggingConfig";
import { MembershipClassType, membershipTiers } from '../models/enums/membershipClassType';

const MembershipSubscription = async () => {
export const MembershipSubscription = async () => {
try {
// Simulate fetching membership subscription data from an API or database
const subscriptionData = {
Expand All @@ -27,4 +28,26 @@ const MembershipSubscription = async () => {
}
}

export default MembershipSubscription
export const isExpired = (date?: Date): boolean => !date || date < new Date();

export const isOnlineClass = (tier: MembershipClassType): boolean =>
[
MembershipClassType.GOLD,
MembershipClassType.DOUBLE_GOLD,
MembershipClassType.TRIPLE_GOLD,
MembershipClassType.GREEN,
].includes(tier);

export const isInstoreClass = (tier: MembershipClassType): boolean =>
tier === MembershipClassType.SINGLE || tier === MembershipClassType.WHITE;

export const isSameCategory = (a: MembershipClassType, b: MembershipClassType): boolean =>
(isOnlineClass(a) && isOnlineClass(b)) || (isInstoreClass(a) && isInstoreClass(b));

export const getTierByClass = (tierClass: MembershipClassType) => {
return Object.values(membershipTiers).find((tier) => tier.CLASS === tierClass);
};

export const getTierRank = (tierClass: MembershipClassType): number => {
return getTierByClass(tierClass)?.RANK ?? -1;
};
52 changes: 36 additions & 16 deletions src/helpers/payment.ts
Copy link
Member

@DarinHajou DarinHajou Jul 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work.

  1. In processPaymentCompletion, we’re calling updateOrRenewMembership(currentUser, membership_class). I think that function expects the full payment object now? Might need to switch to updateOrRenewMembershipAfterPayment(currentPayment) to make sure metadata gets handled properly.

  2. We’re accessing metadata.MembershipPayment without any validation. If the frontend ever sends malformed data, it could break things or corrupt state. Probably worth adding a type check here.

  3. In buildPaymentData, looks like we assume metadata.payment_type is always present. Might be safer to add a fallback or log a warning just in case it’s missing.

  4. If payment_type is unknown, it seems to fail silently. A quick logger.warn() might help with debugging unexpected cases.

Copy link
Member

@swoocn swoocn Jul 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are quite a number of anti-patterns in this helper file that will make it harder to maintain long-term i.e., tight coupling, duplication, and overall bloated code. Even just getting the unit tests rewritten to fix the CI/CD pipeline was a bit of a struggle because of the very procedural code structure. 😵

That said, I think it's better we take the time to clean it up now rather than let technical debt pile up. I've already started refactoring on a separate branch, so I'll plan to consolidate that work into this one and update the PR—but I'll do that after I finish reviewing the rest of the changes here.

Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import axios from 'axios';
import { platformAPIClient } from '../config/platformAPIclient';
import logger from '../config/loggingConfig';
import Seller from '../models/Seller';
import User from '../models/User';
import { MembershipClassType } from "../models/enums/membershipClassType";
import { OrderStatusType } from '../models/enums/orderStatusType';
import { PaymentType } from "../models/enums/paymentType";
import { U2UPaymentStatus } from '../models/enums/u2uPaymentStatus';
Expand All @@ -18,20 +20,19 @@ import {
createA2UPayment,
cancelPayment
} from '../services/payment.service';
import { IUser, NewOrder, PaymentDataType, PaymentDTO, PaymentInfo } from '../types';
import logger from '../config/loggingConfig';
import { updateOrRenewMembership } from "../services/membership.service";
import { NewOrder, PaymentDataType, PaymentDTO, PaymentInfo } from '../types';

function buildPaymentData(
piPaymentId: string,
buyerId: string,
payment: PaymentDataType
) {
return {
piPaymentId,
userId: buyerId,
userId: payment.user_id,
memo: payment.memo,
amount: payment.amount,
paymentType: PaymentType.BuyerCheckout
paymentType: payment.metadata.payment_type
};
}

Expand All @@ -57,7 +58,6 @@ function buildOrderData(

const checkoutProcess = async (
piPaymentId: string,
authUser: IUser,
currentPayment: PaymentDataType
) => {

Expand All @@ -71,15 +71,15 @@ const checkoutProcess = async (

// Look up the seller and buyer in the database
const seller = await Seller.findOne({ seller_id: OrderPayment.seller });
const buyer = await User.findOne({ pi_uid: authUser?.pi_uid });
const buyer = await User.findOne({ pi_uid: currentPayment.user_id });

if (!buyer || !seller) {
logger.error("Seller or buyer not found", { sellerId: OrderPayment.seller, buyerId: authUser?.pi_uid });
logger.error("Seller or buyer not found", { sellerId: OrderPayment.seller, buyerId: currentPayment.user_id });
throw new Error("Seller or buyer not found");
}

// Construct payment data object for recording the transaction
const paymentData = buildPaymentData(piPaymentId, buyer._id as string, currentPayment);
const paymentData = buildPaymentData(piPaymentId, currentPayment);
// Create a new payment record
const newPayment = await createPayment(paymentData)
// Validate payment record creation succeeded
Expand All @@ -96,13 +96,13 @@ const checkoutProcess = async (

// Construct order data object
const orderData = buildOrderData(
authUser.pi_uid as string,
currentPayment.user_id as string,
OrderPayment.seller as string,
newPayment._id as string,
currentPayment
)
// Create a new order along with its items
const newOrder = await createOrder(orderData as NewOrder, OrderPayment.items, authUser);
const newOrder = await createOrder(orderData as NewOrder, OrderPayment.items, currentPayment.user_id);

logger.info('order created successfully', { orderId: newOrder._id });
return newOrder;
Expand Down Expand Up @@ -139,6 +139,14 @@ export const processIncompletePayment = async (payment: PaymentInfo) => {
if (updatedPayment?.payment_type === PaymentType.BuyerCheckout) {
await updatePaidOrder(updatedPayment._id as string);
logger.warn("Old order found and updated");

} else if (updatedPayment.payment_type === PaymentType.Membership) {
// Fetch payment details from the Pi platform using the payment ID
const res = await platformAPIClient.get(`/v2/payments/${ paymentId }`);

const currentPayment: PaymentDataType = res.data;
const membership_class = currentPayment.metadata.MembershipPayment?.membership_class as MembershipClassType
await updateOrRenewMembership(currentPayment.user_id, membership_class);
}

// Notify the Pi Platform that the payment is complete
Expand All @@ -165,7 +173,6 @@ export const processIncompletePayment = async (payment: PaymentInfo) => {

export const processPaymentApproval = async (
paymentId: string,
currentUser: IUser
): Promise<{ success: boolean; message: string }> => {
try {
// Fetch payment details from the Pi platform using the payment ID
Expand All @@ -176,7 +183,7 @@ export const processPaymentApproval = async (
const oldPayment = await getPayment(res.data.identifier);
if (oldPayment) {
logger.info("Payment record already exists: ", oldPayment._id);

await processPaymentError(res.data);
return {
success: false,
message: `Payment already exists with ID ${ paymentId }`,
Expand All @@ -185,10 +192,12 @@ export const processPaymentApproval = async (

// Handle logic based on the payment type
if (currentPayment?.metadata.payment_type === PaymentType.BuyerCheckout) {
const newOrder = await checkoutProcess(paymentId, currentUser, currentPayment);
const newOrder = await checkoutProcess(paymentId, currentPayment);
logger.info("Order created successfully: ", newOrder._id);
} else if (currentPayment?.metadata.payment_type === PaymentType.Membership) {
logger.info("Membership subscription processed successfully");
// Create a new payment record
const paymentData = buildPaymentData(paymentId, currentPayment );
await createPayment(paymentData);
}

// Approve the payment on the Pi platform
Expand Down Expand Up @@ -216,11 +225,13 @@ export const processPaymentApproval = async (
export const processPaymentCompletion = async (paymentId: string, txid: string) => {
try {
// Confirm the payment exists via Pi platform API
await platformAPIClient.get(`/v2/payments/${ paymentId }`);
const res = await platformAPIClient.get(`/v2/payments/${ paymentId }`);
const currentPayment: PaymentDataType = res.data;

// Mark the payment as completed
const completedPayment = await completePayment(paymentId, txid);
logger.info("Payment record marked as completed");

if (completedPayment?.payment_type === PaymentType.BuyerCheckout) {
// Update the associated order's status to paid
const order = await updatePaidOrder(completedPayment._id as string);
Expand Down Expand Up @@ -257,9 +268,18 @@ export const processPaymentCompletion = async (paymentId: string, txid: string)
});

} else if (completedPayment?.payment_type === PaymentType.Membership) {

const membership_class = currentPayment.metadata.MembershipPayment?.membership_class as MembershipClassType
const membership = await updateOrRenewMembership(currentPayment.user_id, membership_class);

// Notify Pi platform for membership payment completion
await platformAPIClient.post(`/v2/payments/${ paymentId }/complete`, { txid });
logger.info("Membership subscription completed");
return {
success: true,
message: `Payment completed with id ${ paymentId }`,
membership: membership
};
}

return {
Expand Down
35 changes: 35 additions & 0 deletions src/middlewares/isMembershipFound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextFunction, Request, Response } from "express";
import Membership from "../models/Membership";
import { IMembership } from "../types";
import logger from '../config/loggingConfig';

declare module 'express-serve-static-core' {
interface Request {
currentMembership: IMembership;
}
}

export const isMembershipFound = async (
req: Request,
res: Response,
next: NextFunction
) => {
const membership_id = req.currentUser?.pi_uid;

try {
logger.info(`Checking if membership exists for user ID: ${membership_id}`);
const currentMembership: IMembership | null = await Membership.findOne({pi_uid: membership_id});

if (currentMembership) {
req.currentMembership = currentMembership;
logger.info(`Membership found: ${currentMembership._id}`);
return next();
} else {
logger.warn(`Membership not found for user ID: ${membership_id}`);
return res.status(404).json({message: "Membership not found"});
}
} catch (error) {
logger.error('Failed to identify membership:', error);
res.status(500).json({ message: 'Failed to identify | membership not found; please try again later'});
}
};
Loading