Skip to content

Commit

Permalink
add various updates (major refactor)
Browse files Browse the repository at this point in the history
  • Loading branch information
stepanjakl committed May 24, 2024
1 parent 5746729 commit fae3a1f
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 110 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
.idea
node_modules
package-lock.json
test/data
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

<br>

This module adds a piece module and utility operation to automatically synchronize Stripe Products with the database. Saved products can be easily accessed and viewed via the admin UI.
This module adds a piece module and utility operation to automatically synchronize Stripe Products with the database. Saved products can be easily accessed and viewed via the admin UI alongside other piece methods and features, including the built-in REST API.

<br>

Expand Down Expand Up @@ -133,7 +133,32 @@ module.exports = {

## API Routes

The `stripe-products` module contains a custom API route (`'/api/v1/stripe-products/synchronize'`) triggered by the `Synchronize Products` utility operation. It is executed through the `'@apostrophecms/job'` module. Once the job is completed, it saves the difference between the existing and received data to the results object in the `aposJobs` collection document.
#### `'/api/v1/stripe-products/synchronize'`:

This API route is triggered by the `Synchronize Products` utility operation and handled via the `'@apostrophecms/job'` module. Upon execution, it compares the existing data with the received data and records the differences in the results object within the `aposJobs` collection document. Access to this endpoint is restricted to authenticated users with appropriate permissions.

<br>

#### `'/api/v1/stripe-products/product'`:

Apostrophe provides a pre-configured REST endpoint to retrieve a list of all available products. Below is an example of how to use the Fetch API with error handling directly in the browser:

```javascript
try {
const response = await fetch('/api/v1/stripe-products/product');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const { results: products } = await response.json();

products.forEach(product => {
console.log('Product Name:', product.stripeProductObject.name);
});

} catch (error) {
console.error('Error fetching products:', error);
}
```

<br>

Expand All @@ -156,4 +181,5 @@ Once set up, run tests using `npm run tests` to validate any changes before depl
## TODOs (Limitations)

- fix disappering `stripeProductObject` and `stripePriceObject` data when moved between `draft` and `published` modes and vice versa
- optional product piece type REST API or configurable schema fields
- two-way synchronization between ApostropheCMS and Stripe
257 changes: 150 additions & 107 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ const _ = require('lodash');
/**
* Deep diff between two object-likes
* https://gist.github.com/Yimiprod/7ee176597fef230d1451?permalink_comment_id=4757803#gistcomment-4757803
* @param {Object} fromObject the original object
* @param {Object} toObject the updated object
* @return {Object} a new object which represents the difference
*/
function deepDiff(fromObject, toObject) {
const changes = {};
Expand Down Expand Up @@ -67,14 +64,12 @@ const Stripe = require('stripe');
let stripe = null;

if (process.env.STRIPE_MOCK_TEST_MODE === 'true') {
// Using Stripe mock test mode settings
stripe = new Stripe('sk_test_xyz', {
host: '127.0.0.1',
protocol: 'http',
port: 12111
});
} else {
// Using Stripe production mode settings with the API key
stripe = Stripe(process.env.STRIPE_KEY);
}

Expand All @@ -91,12 +86,11 @@ module.exports = {
modules: getBundleModuleNames()
},
init(self) {
// self.options.stripeKey = process.env.STRIPE_KEY || self.options.stripeKey;
/* TODO self.options.stripeKey = process.env.STRIPE_KEY || self.options.stripeKey; */

const groupName = 'stripe';
const itemsToAdd = [ 'stripe-products/product' ];

// Check if 'stripe' already exists in self.apos.adminBar.groups
const existingStripeGroup = self.apos.adminBar.groups.find(group => group.name === groupName);

const newStripeGroup = {
Expand All @@ -105,7 +99,6 @@ module.exports = {
items: itemsToAdd
};

// If 'stripe' exists, add items to the existing one; otherwise, create a new group
if (existingStripeGroup) {
existingStripeGroup.items = existingStripeGroup.items.concat(itemsToAdd);
} else {
Expand All @@ -117,124 +110,174 @@ module.exports = {
post: {
// POST /api/v1/stripe-products/synchronize
'/api/v1/stripe-products/synchronize': async function (req) {
// Check if the user is authorized as an editor or admin
class ReportingHandler {
constructor(reporting) {
this.reporting = reporting;
this.differenceResults = {};
}

recordDifferences(docToUpdate, product, price) {
const differenceProductObject = _.deepDiff(docToUpdate.stripeProductObject, product);
const differencePriceObject = product.default_price ? _.deepDiff(docToUpdate.stripePriceObject, price) : null;

if (!_.isEmpty(differenceProductObject)) {
this.differenceResults[docToUpdate._id] = {
stripeProductObject: {
difference: differenceProductObject
}
};
}
if (!_.isEmpty(differencePriceObject)) {
this.differenceResults[docToUpdate._id] = {
stripePriceObject: {
difference: differencePriceObject
}
};
}

return {
differenceProductObject,
differencePriceObject
};
}

setResults() {
this.reporting.setResults(this.differenceResults);
}

async setTotalDocuments(req) {
const totalDocs = await self.apos.stripeProduct.findForEditing(req.clone({ mode: 'draft' })).toCount();
await this.reporting.setTotal(Math.floor(totalDocs / 2));
}

success() {
this.reporting.success();
}
}

if (req.user && (req.user.role === 'editor' || req.user.role === 'admin')) {
let jobReporting;

// Run a job using Apostrophe's job module
const job = await self.apos.modules['@apostrophecms/job'].run(req, async (req, reporting, info) => {
jobReporting = reporting;
/* TODO req.user && (req.user._permissions || {}) */

// Set total number of documents to synchronize
await reporting.setTotal(Math.round(await self.apos.stripeProduct.find(req).toCount() / 4));
let jobResolve, reporting;
const jobPromise = new Promise((resolve, reject) => {
jobResolve = resolve;
});

// Object to store differences between documents
const differenceResults = {};
let productList = [];
let startingAfterId;

// Continuous loop for pagination
while (true) {
// Fetch products from Stripe with pagination
const products = await stripe.products.list({
limit: 2,
starting_after: startingAfterId
});
const job = await self.apos.modules['@apostrophecms/job'].run(req, async (req, r) => {
reporting = r;
await jobPromise;
});

const reportingHandler = new ReportingHandler(reporting);

productList = [ ...productList, ...products.data ];
const getProductList = async (startingAfterId) => {
try {
return await stripe.products.list({
limit: 2,
starting_after: startingAfterId
});
} catch (error) {
console.error(`Stripe API error: ${error.message}`);
throw error;
}
};

// Process each product fetched from Stripe
for (const product of products?.data || []) {
// Wrap each iteration in a promise to ensure that all operations complete before moving to the next iteration
const getPriceInfo = async (product) => {
if (product.default_price) {
try {
// Convert UNIX timestamps to ISO format
product.created_timestamp = new Date(product.created * 1000).toISOString();
product.updated_timestamp = new Date(product.updated * 1000).toISOString();

// Retrieve price information if available
let price = null;
if (product.default_price) {
price = await stripe.prices.retrieve(product.default_price);
// Convert price from cents to dollars
price.unit_amount = (price.unit_amount / 100).toFixed(2);
price.created_timestamp = new Date(price.created * 1000).toISOString();
}
const price = await stripe.prices.retrieve(product.default_price);
price.unit_amount = (price.unit_amount / 100).toFixed(2);
price.created_timestamp = new Date(price.created * 1000).toISOString();
return price;
} catch (error) {
console.error(`Stripe API error: ${error.message}`);
throw error;
}
}
return null;
};

// Check if the product exists in the database
const docToUpdate = await self.apos.stripeProduct.findOneForEditing(req.clone({
mode: 'draft'
}), {
'stripeProductObject.id': product.id
});

if (docToUpdate) {
// Determine differences in product and price objects
const differenceProductObject = _.deepDiff(docToUpdate.stripeProductObject, product);
const differencePriceObject = product.default_price ? _.deepDiff(docToUpdate.stripePriceObject, price) : null;

// Update the document if differences are found
if (!_.isEmpty(differenceProductObject) || !_.isEmpty(differencePriceObject)) {
docToUpdate.stripeProductObject = product;
docToUpdate.stripePriceObject = price;

// Include 'difference' objects only if they are not empty
if (!_.isEmpty(differenceProductObject)) {
differenceResults[docToUpdate._id] = {
stripeProductObject: {
difference: differenceProductObject
}
};
}
if (!_.isEmpty(differencePriceObject)) {
differenceResults[docToUpdate._id] = {
stripePriceObject: {
difference: differencePriceObject
}
};
}
const findDocToUpdate = async (req, product) => {
return await self.apos.stripeProduct.findOneForEditing(req.clone({ mode: 'draft' }), {
'stripeProductObject.id': product.id
});
};

// Set archived status based on product's active status
docToUpdate.archived = !product.active;
const updateDocument = async (req, docToUpdate, product, price) => {
docToUpdate.stripeProductObject = product;
docToUpdate.stripePriceObject = price;
docToUpdate.archived = !product.active;
docToUpdate.updatedAt = new Date();
docToUpdate.stripeProductObject.created_timestamp = new Date(product.created * 1000).toISOString();
docToUpdate.stripeProductObject.updated_timestamp = new Date(product.updated * 1000).toISOString();
await self.apos.stripeProduct.update(req, docToUpdate);

docToUpdate.updatedAt = new Date();
if (!docToUpdate.archived) {
await self.apos.stripeProduct.publish(req, docToUpdate);
}
};

await self.apos.stripeProduct.update(req, docToUpdate);
}
} else {
// Insert a new document if it doesn't exist
const stripeProductInstance = self.apos.stripeProduct.newInstance();
stripeProductInstance.title = product.name;
stripeProductInstance.slug = self.apos.util.slugify(product.name);
stripeProductInstance.stripeProductObject = product;
stripeProductInstance.stripePriceObject = product.default_price ? price : null;
const insertDocument = async (req, product, price) => {
const stripeProductInstance = self.apos.stripeProduct.newInstance();
stripeProductInstance.title = product.name;
stripeProductInstance.slug = self.apos.util.slugify(product.name);
stripeProductInstance.stripeProductObject = product;
stripeProductInstance.stripePriceObject = price;
stripeProductInstance.archived = !product.active;
stripeProductInstance.stripeProductObject.created_timestamp = new Date(product.created * 1000).toISOString();
stripeProductInstance.stripeProductObject.updated_timestamp = new Date(product.updated * 1000).toISOString();
await self.apos.stripeProduct.insert(req, stripeProductInstance);
};

const handlePaginationAndSync = async (req) => {
let productList = [];
let startingAfterId;

while (true) {
const products = await getProductList(startingAfterId);
productList = [ ...productList, ...products.data ];

stripeProductInstance.archived = !product.active;
for (const product of products?.data || []) {
try {
const price = await getPriceInfo(product);
const docToUpdate = await findDocToUpdate(req, product);

await self.apos.stripeProduct.insert(req, stripeProductInstance);
if (docToUpdate) {
const {
differenceProductObject,
differencePriceObject
} = reportingHandler.recordDifferences(docToUpdate, product, price);

if (!_.isEmpty(differenceProductObject) || !_.isEmpty(differencePriceObject)) {
await updateDocument(req, docToUpdate, product, price);
}
} else {
await insertDocument(req, product, price);
}
} catch (error) {
console.error('Error occurred while processing product:', product.id, error);
}
} catch (error) {
console.error('Error occurred while processing product:', error); // Log the error
}
}

// Update startingAfterId for the next request
startingAfterId = products.data.length > 0 ? products.data[products.data.length - 1].id : undefined;

// Check if there are more products to fetch
if (!products.has_more) {
// Finalize the job and pass doc changes to the results field
jobReporting.setResults(differenceResults);
jobReporting.success();
startingAfterId = products.data.length > 0 ? products.data[products.data.length - 1].id : undefined;
reportingHandler.success();

// Return job information, product list, and difference results
return {
job,
productList,
differenceResults
};
if (!products.has_more) {
reportingHandler.setResults();
jobResolve();
return {
job,
productList,
differenceResults: reportingHandler.differenceResults
};
}
}
}
};

await reportingHandler.setTotalDocuments(req);

return await handlePaginationAndSync(req);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stepanjakl/apostrophe-stripe-products",
"version": "0.0.5",
"version": "0.0.7",
"description": "Stripe Products For ApostropheCMS",
"keywords": [
"apostrophe",
Expand Down

0 comments on commit fae3a1f

Please sign in to comment.