diff --git a/backend/src/db/api/review_requests.js b/backend/src/db/api/review_requests.js
index 1b56374..9ac9e06 100644
--- a/backend/src/db/api/review_requests.js
+++ b/backend/src/db/api/review_requests.js
@@ -1,7 +1,5 @@
const db = require('../models');
-const FileDBApi = require('./file');
-const crypto = require('crypto');
const Utils = require('../utils');
@@ -382,15 +380,12 @@ module.exports = class Review_requestsDBApi {
offset = currentPage * limit;
- const orderBy = null;
-
- const transaction = (options && options.transaction) || undefined;
-
let include = [
{
model: db.businesses,
as: 'business',
+ required: Boolean(filter.business),
where: filter.business ? {
[Op.or]: [
@@ -408,6 +403,7 @@ module.exports = class Review_requestsDBApi {
{
model: db.customers,
as: 'customer',
+ required: Boolean(filter.customer),
where: filter.customer ? {
[Op.or]: [
@@ -425,6 +421,7 @@ module.exports = class Review_requestsDBApi {
{
model: db.transactions,
as: 'transaction',
+ required: Boolean(filter.transaction),
where: filter.transaction ? {
[Op.or]: [
diff --git a/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js b/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js
new file mode 100644
index 0000000..3c873ac
--- /dev/null
+++ b/backend/src/db/migrations/20260629030000-add-reviewflow-payment-webhooks.js
@@ -0,0 +1,112 @@
+'use strict';
+
+const businessColumns = {
+ stripe_webhook_token: { type: 'TEXT' },
+ square_account_reference: { type: 'TEXT' },
+ square_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
+ square_connected_at: { type: 'DATE' },
+ square_webhook_token: { type: 'TEXT' },
+ paypal_merchant_reference: { type: 'TEXT' },
+ paypal_connected: { type: 'BOOLEAN', defaultValue: false, allowNull: false },
+ paypal_connected_at: { type: 'DATE' },
+ paypal_webhook_token: { type: 'TEXT' },
+};
+
+const customerColumns = {
+ square_customer_reference: { type: 'TEXT' },
+ paypal_customer_reference: { type: 'TEXT' },
+};
+
+const transactionColumns = {
+ businessId: { type: 'UUID', references: { model: 'businesses', key: 'id' } },
+ payment_provider: { type: 'TEXT' },
+ square_payment_reference: { type: 'TEXT' },
+ paypal_payment_reference: { type: 'TEXT' },
+ provider_event_reference: { type: 'TEXT' },
+};
+
+const eventColumns = {
+ provider: { type: 'TEXT' },
+ provider_event_type: { type: 'TEXT' },
+};
+
+function normalizeColumnDefinition(Sequelize, definition) {
+ const normalized = { ...definition };
+
+ if (definition.type === 'TEXT') {
+ normalized.type = Sequelize.DataTypes.TEXT;
+ }
+
+ if (definition.type === 'BOOLEAN') {
+ normalized.type = Sequelize.DataTypes.BOOLEAN;
+ }
+
+ if (definition.type === 'DATE') {
+ normalized.type = Sequelize.DataTypes.DATE;
+ }
+
+ if (definition.type === 'UUID') {
+ normalized.type = Sequelize.DataTypes.UUID;
+ }
+
+ return normalized;
+}
+
+async function addColumnsIfMissing(queryInterface, Sequelize, transaction, tableName, columns) {
+ const table = await queryInterface.describeTable(tableName);
+
+ for (const [columnName, definition] of Object.entries(columns)) {
+ if (!table[columnName]) {
+ await queryInterface.addColumn(
+ tableName,
+ columnName,
+ normalizeColumnDefinition(Sequelize, definition),
+ { transaction },
+ );
+ }
+ }
+}
+
+async function removeColumnsIfPresent(queryInterface, transaction, tableName, columns) {
+ const table = await queryInterface.describeTable(tableName);
+
+ for (const columnName of Object.keys(columns).reverse()) {
+ if (table[columnName]) {
+ await queryInterface.removeColumn(tableName, columnName, { transaction });
+ }
+ }
+}
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'businesses', businessColumns);
+ await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'customers', customerColumns);
+ await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'transactions', transactionColumns);
+ await addColumnsIfMissing(queryInterface, Sequelize, transaction, 'stripe_events', eventColumns);
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+
+ async down(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+
+ try {
+ await removeColumnsIfPresent(queryInterface, transaction, 'stripe_events', eventColumns);
+ await removeColumnsIfPresent(queryInterface, transaction, 'transactions', transactionColumns);
+ await removeColumnsIfPresent(queryInterface, transaction, 'customers', customerColumns);
+ await removeColumnsIfPresent(queryInterface, transaction, 'businesses', businessColumns);
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ },
+};
diff --git a/backend/src/db/models/businesses.js b/backend/src/db/models/businesses.js
index 8ae6850..8e87011 100644
--- a/backend/src/db/models/businesses.js
+++ b/backend/src/db/models/businesses.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const businesses = sequelize.define(
'businesses',
@@ -95,6 +89,75 @@ stripe_connected_at: {
+ },
+
+stripe_webhook_token: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+square_account_reference: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+square_connected: {
+ type: DataTypes.BOOLEAN,
+
+ allowNull: false,
+ defaultValue: false,
+
+
+
+ },
+
+square_connected_at: {
+ type: DataTypes.DATE,
+
+
+
+ },
+
+square_webhook_token: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+paypal_merchant_reference: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+paypal_connected: {
+ type: DataTypes.BOOLEAN,
+
+ allowNull: false,
+ defaultValue: false,
+
+
+
+ },
+
+paypal_connected_at: {
+ type: DataTypes.DATE,
+
+
+
+ },
+
+paypal_webhook_token: {
+ type: DataTypes.TEXT,
+
+
+
},
default_review_platform: {
@@ -176,6 +239,14 @@ custom_review_link: {
constraints: false,
});
+ db.businesses.hasMany(db.transactions, {
+ as: 'transactions_business',
+ foreignKey: {
+ name: 'businessId',
+ },
+ constraints: false,
+ });
+
diff --git a/backend/src/db/models/customers.js b/backend/src/db/models/customers.js
index 4819aea..523a5fe 100644
--- a/backend/src/db/models/customers.js
+++ b/backend/src/db/models/customers.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const customers = sequelize.define(
'customers',
@@ -40,6 +34,20 @@ stripe_customer_reference: {
+ },
+
+square_customer_reference: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+paypal_customer_reference: {
+ type: DataTypes.TEXT,
+
+
+
},
contact_status: {
diff --git a/backend/src/db/models/stripe_events.js b/backend/src/db/models/stripe_events.js
index 0302d7e..fd0e232 100644
--- a/backend/src/db/models/stripe_events.js
+++ b/backend/src/db/models/stripe_events.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const stripe_events = sequelize.define(
'stripe_events',
@@ -19,6 +13,20 @@ stripe_event_reference: {
+ },
+
+provider: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+provider_event_type: {
+ type: DataTypes.TEXT,
+
+
+
},
event_type: {
diff --git a/backend/src/db/models/transactions.js b/backend/src/db/models/transactions.js
index b1af6b5..6972319 100644
--- a/backend/src/db/models/transactions.js
+++ b/backend/src/db/models/transactions.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const transactions = sequelize.define(
'transactions',
@@ -19,6 +13,34 @@ stripe_payment_reference: {
+ },
+
+payment_provider: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+square_payment_reference: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+paypal_payment_reference: {
+ type: DataTypes.TEXT,
+
+
+
+ },
+
+provider_event_reference: {
+ type: DataTypes.TEXT,
+
+
+
},
amount: {
@@ -131,6 +153,14 @@ receipt_email: {
constraints: false,
});
+ db.transactions.belongsTo(db.businesses, {
+ as: 'business',
+ foreignKey: {
+ name: 'businessId',
+ },
+ constraints: false,
+ });
+
diff --git a/backend/src/index.js b/backend/src/index.js
index 57813fe..dd19b8a 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -35,6 +34,9 @@ const transactionsRoutes = require('./routes/transactions');
const review_requestsRoutes = require('./routes/review_requests');
+const reviewflowRoutes = require('./routes/reviewflow');
+const reviewflowWebhooksRoutes = require('./routes/reviewflow-webhooks');
+
const stripe_eventsRoutes = require('./routes/stripe_events');
const email_delivery_logsRoutes = require('./routes/email_delivery_logs');
@@ -113,6 +115,10 @@ app.use('/api/transactions', passport.authenticate('jwt', {session: false}), tra
app.use('/api/review_requests', passport.authenticate('jwt', {session: false}), review_requestsRoutes);
+app.use('/api/reviewflow', passport.authenticate('jwt', {session: false}), reviewflowRoutes);
+
+app.use('/api/reviewflow-webhooks', reviewflowWebhooksRoutes);
+
app.use('/api/stripe_events', passport.authenticate('jwt', {session: false}), stripe_eventsRoutes);
app.use('/api/email_delivery_logs', passport.authenticate('jwt', {session: false}), email_delivery_logsRoutes);
diff --git a/backend/src/routes/reviewflow-webhooks.js b/backend/src/routes/reviewflow-webhooks.js
new file mode 100644
index 0000000..3687bee
--- /dev/null
+++ b/backend/src/routes/reviewflow-webhooks.js
@@ -0,0 +1,29 @@
+const express = require('express');
+const ReviewFlowService = require('../services/reviewflow');
+const wrapAsync = require('../helpers').wrapAsync;
+
+const router = express.Router();
+
+router.post('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
+ const result = await ReviewFlowService.processPaymentWebhook(
+ req.params.provider,
+ req.params.businessId,
+ req.params.secretToken,
+ req.body,
+ );
+
+ res.status(200).send({ received: true, ...result });
+}));
+
+router.get('/:provider/:businessId/:secretToken', wrapAsync(async (req, res) => {
+ ReviewFlowService.getProviderConfig(req.params.provider);
+
+ res.status(200).send({
+ ok: true,
+ message: 'ReviewFlow webhook URL is reachable. Configure your payment provider to POST JSON events to this same URL.',
+ });
+}));
+
+router.use('/', require('../helpers').commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/routes/reviewflow.js b/backend/src/routes/reviewflow.js
new file mode 100644
index 0000000..d9e7007
--- /dev/null
+++ b/backend/src/routes/reviewflow.js
@@ -0,0 +1,236 @@
+const express = require('express');
+const crypto = require('crypto');
+const db = require('../db/models');
+const ReviewFlowService = require('../services/reviewflow');
+const wrapAsync = require('../helpers').wrapAsync;
+
+const router = express.Router();
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function normalizeString(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function requireField(value, message) {
+ if (!normalizeString(value)) {
+ const error = new Error(message);
+ error.code = 400;
+ throw error;
+ }
+}
+
+function validateUrl(value, message) {
+ try {
+ const parsed = new URL(value);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw new Error(message);
+ }
+ } catch {
+ const validationError = new Error(message);
+ validationError.code = 400;
+ throw validationError;
+ }
+}
+
+function buildEmailBody(customerName, businessName, reviewLink) {
+ const greetingName = customerName || 'there';
+ return [
+ `Hi ${greetingName},`,
+ '',
+ `Thank you for choosing ${businessName}. We would love to hear about your experience.`,
+ '',
+ `Leave a review: ${reviewLink}`,
+ '',
+ `Thank you,`,
+ businessName,
+ ].join('\n');
+}
+
+
+router.get('/connectors', wrapAsync(async (req, res) => {
+ const businesses = await ReviewFlowService.listConnectorBusinesses(req.currentUser, req);
+
+ res.status(200).send({ businesses });
+}));
+
+router.post('/connectors', wrapAsync(async (req, res) => {
+ const business = await ReviewFlowService.connectProvider(req.currentUser, req.body || {}, req);
+
+ res.status(200).send({ business });
+}));
+
+router.post('/connectors/:businessId/:provider/rotate', wrapAsync(async (req, res) => {
+ const business = await ReviewFlowService.rotateWebhookToken(
+ req.currentUser,
+ req.params.businessId,
+ req.params.provider,
+ req,
+ );
+
+ res.status(200).send({ business });
+}));
+
+router.get('/summary', wrapAsync(async (req, res) => {
+ const currentUser = req.currentUser;
+ const limit = Math.min(Number(req.query.limit) || 8, 25);
+
+ const requests = await db.review_requests.findAll({
+ where: { createdById: currentUser.id },
+ include: [
+ { model: db.businesses, as: 'business' },
+ { model: db.customers, as: 'customer' },
+ { model: db.transactions, as: 'transaction' },
+ ],
+ order: [['createdAt', 'DESC']],
+ limit,
+ });
+
+ const [
+ pending,
+ sent,
+ clicked,
+ reviewed,
+ customers,
+ transactions,
+ paymentEvents,
+ recentTransactions,
+ recentEvents,
+ ] = await Promise.all([
+ db.review_requests.count({ where: { createdById: currentUser.id, status: 'pending' } }),
+ db.review_requests.count({ where: { createdById: currentUser.id, status: 'sent' } }),
+ db.review_requests.count({ where: { createdById: currentUser.id, status: 'clicked' } }),
+ db.review_requests.count({ where: { createdById: currentUser.id, status: 'reviewed' } }),
+ db.customers.count({ where: { createdById: currentUser.id } }),
+ db.transactions.count({ where: { createdById: currentUser.id } }),
+ db.stripe_events.count({ where: { createdById: currentUser.id } }),
+ db.transactions.findAll({
+ where: { createdById: currentUser.id },
+ include: [
+ { model: db.businesses, as: 'business' },
+ { model: db.customers, as: 'customer' },
+ ],
+ order: [['createdAt', 'DESC']],
+ limit: 6,
+ }),
+ db.stripe_events.findAll({
+ where: { createdById: currentUser.id },
+ include: [
+ { model: db.businesses, as: 'business' },
+ ],
+ order: [['createdAt', 'DESC']],
+ limit: 6,
+ }),
+ ]);
+
+ res.status(200).send({
+ stats: { pending, sent, clicked, reviewed, customers, transactions, paymentEvents },
+ requests,
+ recentTransactions,
+ recentEvents,
+ });
+}));
+
+router.post('/request', wrapAsync(async (req, res) => {
+ const currentUser = req.currentUser;
+ const body = req.body || {};
+ const businessName = normalizeString(body.businessName);
+ const reviewLink = normalizeString(body.reviewLink);
+ const customerEmail = normalizeString(body.customerEmail).toLowerCase();
+ const customerName = normalizeString(body.customerName);
+ const phone = normalizeString(body.phone);
+ const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
+
+ requireField(businessName, 'Business name is required.');
+ requireField(reviewLink, 'Review link is required.');
+ requireField(customerEmail, 'Customer email is required.');
+
+ if (!EMAIL_PATTERN.test(customerEmail)) {
+ const error = new Error('Enter a valid customer email address.');
+ error.code = 400;
+ throw error;
+ }
+
+ validateUrl(reviewLink, 'Enter a valid Google, Yelp, Facebook, or review page URL.');
+
+ const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const [business] = await db.businesses.findOrCreate({
+ where: { name: businessName, createdById: currentUser.id },
+ defaults: {
+ name: businessName,
+ google_review_link: reviewLink,
+ delay_days: delayDays,
+ email_subject_template: `How was your experience with ${businessName}?`,
+ email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
+ is_active: true,
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ },
+ transaction,
+ });
+
+ await business.update({
+ google_review_link: reviewLink,
+ delay_days: delayDays,
+ is_active: true,
+ updatedById: currentUser.id,
+ }, { transaction });
+
+ const [customer] = await db.customers.findOrCreate({
+ where: { email: customerEmail, createdById: currentUser.id },
+ defaults: {
+ email: customerEmail,
+ name: customerName || null,
+ phone: phone || null,
+ contact_status: 'active',
+ businessId: business.id,
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ },
+ transaction,
+ });
+
+ await customer.update({
+ name: customerName || customer.name,
+ phone: phone || customer.phone,
+ contact_status: customer.contact_status || 'active',
+ businessId: business.id,
+ updatedById: currentUser.id,
+ }, { transaction });
+
+ const emailSubject = `How was your experience with ${businessName}?`;
+ const reviewRequest = await db.review_requests.create({
+ status: 'pending',
+ scheduled_for: scheduledFor,
+ email_subject: emailSubject,
+ email_body: buildEmailBody(customerName, businessName, reviewLink),
+ review_link: reviewLink,
+ tracking_token: crypto.randomBytes(18).toString('hex'),
+ businessId: business.id,
+ customerId: customer.id,
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ }, { transaction });
+
+ await transaction.commit();
+
+ const createdRequest = await db.review_requests.findByPk(reviewRequest.id, {
+ include: [
+ { model: db.businesses, as: 'business' },
+ { model: db.customers, as: 'customer' },
+ ],
+ });
+
+ res.status(201).send({ request: createdRequest });
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+}));
+
+router.use('/', require('../helpers').commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/services/reviewflow.js b/backend/src/services/reviewflow.js
new file mode 100644
index 0000000..209becc
--- /dev/null
+++ b/backend/src/services/reviewflow.js
@@ -0,0 +1,697 @@
+const crypto = require('crypto');
+const db = require('../db/models');
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const ZERO_DECIMAL_CURRENCIES = new Set([
+ 'bif',
+ 'clp',
+ 'djf',
+ 'gnf',
+ 'jpy',
+ 'kmf',
+ 'krw',
+ 'mga',
+ 'pyg',
+ 'rwf',
+ 'ugx',
+ 'vnd',
+ 'vuv',
+ 'xaf',
+ 'xof',
+ 'xpf',
+]);
+
+const PROVIDERS = {
+ stripe: {
+ label: 'Stripe',
+ accountField: 'stripe_account_reference',
+ connectedField: 'stripe_connected',
+ connectedAtField: 'stripe_connected_at',
+ tokenField: 'stripe_webhook_token',
+ paymentReferenceField: 'stripe_payment_reference',
+ customerReferenceField: 'stripe_customer_reference',
+ },
+ square: {
+ label: 'Square',
+ accountField: 'square_account_reference',
+ connectedField: 'square_connected',
+ connectedAtField: 'square_connected_at',
+ tokenField: 'square_webhook_token',
+ paymentReferenceField: 'square_payment_reference',
+ customerReferenceField: 'square_customer_reference',
+ },
+ paypal: {
+ label: 'PayPal',
+ accountField: 'paypal_merchant_reference',
+ connectedField: 'paypal_connected',
+ connectedAtField: 'paypal_connected_at',
+ tokenField: 'paypal_webhook_token',
+ paymentReferenceField: 'paypal_payment_reference',
+ customerReferenceField: 'paypal_customer_reference',
+ },
+};
+
+function normalizeString(value) {
+ return typeof value === 'string' ? value.trim() : '';
+}
+
+function normalizeEmail(value) {
+ return normalizeString(value).toLowerCase();
+}
+
+function httpError(message, code) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function requireField(value, message) {
+ if (!normalizeString(value)) {
+ throw httpError(message, 400);
+ }
+}
+
+function validateUrl(value, message) {
+ try {
+ const parsed = new URL(value);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw httpError(message, 400);
+ }
+ } catch {
+ throw httpError(message, 400);
+ }
+}
+
+function getProviderConfig(provider) {
+ const normalizedProvider = normalizeString(provider).toLowerCase();
+ const config = PROVIDERS[normalizedProvider];
+
+ if (!config) {
+ throw httpError('Unsupported payment provider. Use stripe, square, or paypal.', 400);
+ }
+
+ return { provider: normalizedProvider, ...config };
+}
+
+function generateWebhookToken() {
+ return crypto.randomBytes(24).toString('hex');
+}
+
+function getOwnerId(business) {
+ return business.ownerId || business.createdById || business.updatedById || null;
+}
+
+function getRequestOrigin(req) {
+ const forwardedProto = normalizeString(req.headers['x-forwarded-proto']).split(',')[0];
+ const proto = forwardedProto || req.protocol || 'https';
+ const host = req.get('host');
+
+ return `${proto}://${host}`;
+}
+
+function getWebhookUrl(req, business, provider) {
+ const config = getProviderConfig(provider);
+ const token = business[config.tokenField];
+
+ if (!token) {
+ return '';
+ }
+
+ return `${getRequestOrigin(req)}/api/reviewflow-webhooks/${config.provider}/${business.id}/${token}`;
+}
+
+function getReviewLink(business) {
+ const platform = business.default_review_platform || 'google';
+
+ if (platform === 'custom') {
+ return business.custom_review_link || business.google_review_link || business.yelp_review_link || business.facebook_review_link || '';
+ }
+
+ if (platform === 'yelp') {
+ return business.yelp_review_link || business.google_review_link || business.facebook_review_link || business.custom_review_link || '';
+ }
+
+ if (platform === 'facebook') {
+ return business.facebook_review_link || business.google_review_link || business.yelp_review_link || business.custom_review_link || '';
+ }
+
+ return business.google_review_link || business.custom_review_link || business.yelp_review_link || business.facebook_review_link || '';
+}
+
+function buildEmailBody(customerName, businessName, reviewLink) {
+ const greetingName = customerName || 'there';
+
+ return [
+ `Hi ${greetingName},`,
+ '',
+ `Thank you for choosing ${businessName}. We would love to hear about your experience.`,
+ '',
+ `Leave a review: ${reviewLink}`,
+ '',
+ 'Thank you,',
+ businessName,
+ ].join('\n');
+}
+
+function renderTemplate(template, replacements) {
+ if (!template) {
+ return '';
+ }
+
+ return Object.entries(replacements).reduce((output, [key, value]) => {
+ return output.replace(new RegExp(`{${key}}`, 'g'), value || '');
+ }, template);
+}
+
+function toDate(value) {
+ if (!value) {
+ return new Date();
+ }
+
+ if (typeof value === 'number') {
+ return new Date(value * 1000);
+ }
+
+ const parsed = new Date(value);
+
+ if (Number.isNaN(parsed.getTime())) {
+ return new Date();
+ }
+
+ return parsed;
+}
+
+function minorUnitsToDecimal(amount, currency) {
+ if (amount === null || amount === undefined || amount === '') {
+ return null;
+ }
+
+ const numericAmount = Number(amount);
+
+ if (!Number.isFinite(numericAmount)) {
+ return null;
+ }
+
+ const normalizedCurrency = normalizeString(currency).toLowerCase();
+ const divisor = ZERO_DECIMAL_CURRENCIES.has(normalizedCurrency) ? 1 : 100;
+
+ return (numericAmount / divisor).toFixed(2);
+}
+
+function decimalAmount(value) {
+ if (value === null || value === undefined || value === '') {
+ return null;
+ }
+
+ const numericAmount = Number(value);
+
+ if (!Number.isFinite(numericAmount)) {
+ return null;
+ }
+
+ return numericAmount.toFixed(2);
+}
+
+function normalizeStripeEvent(payload) {
+ const payment = payload?.data?.object || payload?.object || payload || {};
+ const charge = Array.isArray(payment?.charges?.data) ? payment.charges.data[0] : null;
+ const eventType = normalizeString(payload?.type || payment?.object || 'unknown');
+ const currency = normalizeString(payment.currency || charge?.currency || '').toUpperCase();
+ const amount = payment.amount_received ?? payment.amount_paid ?? payment.amount_total ?? payment.amount ?? charge?.amount;
+ const email = normalizeEmail(
+ payment.receipt_email ||
+ payment.customer_email ||
+ payment.customer_details?.email ||
+ payment.billing_details?.email ||
+ charge?.billing_details?.email ||
+ charge?.receipt_email,
+ );
+ const customerName = normalizeString(
+ payment.customer_details?.name ||
+ payment.billing_details?.name ||
+ charge?.billing_details?.name,
+ );
+
+ return {
+ eventReference: normalizeString(payload?.id || payment?.id),
+ providerEventType: eventType,
+ normalizedEventType: normalizeEventType('stripe', eventType, payment),
+ paymentReference: normalizeString(payment.payment_intent || payment.charge || payment.id || charge?.id),
+ amount: minorUnitsToDecimal(amount, currency),
+ currency,
+ email,
+ customerName,
+ phone: normalizeString(payment.customer_details?.phone || payment.billing_details?.phone || charge?.billing_details?.phone),
+ customerReference: normalizeString(payment.customer || charge?.customer),
+ description: normalizeString(payment.description || payment.statement_descriptor || charge?.description || eventType),
+ paidAt: toDate(payment.created || payload?.created),
+ isPaymentSuccess: [
+ 'payment_intent.succeeded',
+ 'charge.succeeded',
+ 'checkout.session.completed',
+ 'invoice.payment_succeeded',
+ ].includes(eventType),
+ };
+}
+
+function normalizeSquareEvent(payload) {
+ const payment = payload?.data?.object?.payment || payload?.payment || payload?.data?.object || payload || {};
+ const eventType = normalizeString(payload?.type || 'unknown');
+ const money = payment.total_money || payment.amount_money || payment.approved_money || {};
+ const currency = normalizeString(money.currency || '').toUpperCase();
+ const paymentStatus = normalizeString(payment.status).toUpperCase();
+
+ return {
+ eventReference: normalizeString(payload?.event_id || payload?.id || payment.id),
+ providerEventType: eventType,
+ normalizedEventType: normalizeEventType('square', eventType, payment),
+ paymentReference: normalizeString(payment.id || payload?.event_id || payload?.id),
+ amount: minorUnitsToDecimal(money.amount, currency),
+ currency,
+ email: normalizeEmail(payment.buyer_email_address || payment.customer?.email_address),
+ customerName: normalizeString(payment.customer?.given_name || payment.customer?.family_name),
+ phone: normalizeString(payment.customer?.phone_number),
+ customerReference: normalizeString(payment.customer_id || payment.customer?.id),
+ description: normalizeString(payment.note || payment.receipt_url || eventType),
+ paidAt: toDate(payment.created_at || payment.updated_at || payload?.created_at),
+ isPaymentSuccess: eventType.startsWith('payment.') && paymentStatus === 'COMPLETED',
+ };
+}
+
+function normalizePaypalEvent(payload) {
+ const resource = payload?.resource || payload || {};
+ const eventType = normalizeString(payload?.event_type || payload?.type || 'unknown');
+ const amount = resource.amount || resource.seller_receivable_breakdown?.gross_amount || {};
+ const payer = resource.payer || payload?.payer || {};
+
+ return {
+ eventReference: normalizeString(payload?.id || resource.id),
+ providerEventType: eventType,
+ normalizedEventType: normalizeEventType('paypal', eventType, resource),
+ paymentReference: normalizeString(resource.id || payload?.id),
+ amount: decimalAmount(amount.value),
+ currency: normalizeString(amount.currency_code || amount.currency || '').toUpperCase(),
+ email: normalizeEmail(payer.email_address || resource.email_address),
+ customerName: normalizeString([payer.name?.given_name, payer.name?.surname].filter(Boolean).join(' ')),
+ phone: normalizeString(payer.phone?.phone_number?.national_number),
+ customerReference: normalizeString(payer.payer_id || resource.payer_id),
+ description: normalizeString(resource.description || resource.invoice_id || resource.custom_id || eventType),
+ paidAt: toDate(resource.create_time || resource.update_time || payload?.create_time),
+ isPaymentSuccess: [
+ 'PAYMENT.CAPTURE.COMPLETED',
+ 'PAYMENT.SALE.COMPLETED',
+ 'CHECKOUT.ORDER.COMPLETED',
+ ].includes(eventType),
+ };
+}
+
+function normalizeEventType(provider, providerEventType, payment) {
+ const eventType = normalizeString(providerEventType);
+ const status = normalizeString(payment?.status).toLowerCase();
+
+ if (provider === 'stripe') {
+ if (eventType === 'charge.succeeded') return 'charge_succeeded';
+ if (eventType === 'payment_intent.succeeded' || eventType === 'checkout.session.completed' || eventType === 'invoice.payment_succeeded') return 'payment_intent_succeeded';
+ if (eventType === 'charge.refunded') return 'charge_refunded';
+ if (eventType === 'charge.failed' || eventType === 'payment_intent.payment_failed') return 'charge_failed';
+ }
+
+ if (provider === 'square') {
+ if (eventType.startsWith('refund.')) return 'charge_refunded';
+ if (status === 'failed' || status === 'canceled') return 'charge_failed';
+ if (status === 'completed') return 'payment_intent_succeeded';
+ }
+
+ if (provider === 'paypal') {
+ if (eventType.includes('REFUND')) return 'charge_refunded';
+ if (eventType.includes('DENIED') || eventType.includes('FAILED')) return 'charge_failed';
+ if (eventType.includes('COMPLETED')) return 'payment_intent_succeeded';
+ }
+
+ return 'unknown';
+}
+
+function normalizePaymentEvent(provider, payload) {
+ if (provider === 'stripe') {
+ return normalizeStripeEvent(payload);
+ }
+
+ if (provider === 'square') {
+ return normalizeSquareEvent(payload);
+ }
+
+ return normalizePaypalEvent(payload);
+}
+
+function serializeBusiness(req, business) {
+ return {
+ id: business.id,
+ name: business.name,
+ google_review_link: business.google_review_link,
+ yelp_review_link: business.yelp_review_link,
+ facebook_review_link: business.facebook_review_link,
+ custom_review_link: business.custom_review_link,
+ default_review_platform: business.default_review_platform,
+ delay_days: business.delay_days,
+ providers: Object.keys(PROVIDERS).map((providerKey) => {
+ const config = getProviderConfig(providerKey);
+ const token = business[config.tokenField] || '';
+
+ return {
+ key: providerKey,
+ label: config.label,
+ connected: Boolean(business[config.connectedField]),
+ connected_at: business[config.connectedAtField] || null,
+ account_reference: business[config.accountField] || '',
+ webhook_token: token,
+ webhook_token_last4: token ? token.slice(-4) : '',
+ webhook_url: getWebhookUrl(req, business, providerKey),
+ };
+ }),
+ };
+}
+
+async function listConnectorBusinesses(currentUser, req) {
+ const businesses = await db.businesses.findAll({
+ where: { createdById: currentUser.id },
+ order: [['updatedAt', 'DESC']],
+ limit: 50,
+ });
+
+ return businesses.map((business) => serializeBusiness(req, business));
+}
+
+async function connectProvider(currentUser, body, req) {
+ const config = getProviderConfig(body.provider);
+ const businessId = normalizeString(body.businessId);
+ const businessName = normalizeString(body.businessName);
+ const reviewLink = normalizeString(body.reviewLink);
+ const accountReference = normalizeString(body.accountReference);
+ const delayDays = Math.max(0, Math.min(Number(body.delayDays) || 0, 30));
+ let business;
+
+ if (businessId) {
+ business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } });
+
+ if (!business) {
+ throw httpError('Business not found for this account.', 404);
+ }
+ } else {
+ requireField(businessName, 'Business name is required.');
+ business = await db.businesses.findOne({ where: { name: businessName, createdById: currentUser.id } });
+ }
+
+ if (reviewLink) {
+ validateUrl(reviewLink, 'Enter a valid review page URL before connecting a webhook.');
+ }
+
+ if (!business) {
+ business = await db.businesses.create({
+ name: businessName,
+ google_review_link: reviewLink || null,
+ delay_days: delayDays,
+ email_subject_template: `How was your experience with ${businessName}?`,
+ email_body_template: buildEmailBody('{customerName}', businessName, '{reviewLink}'),
+ is_active: true,
+ createdById: currentUser.id,
+ updatedById: currentUser.id,
+ ownerId: currentUser.id,
+ });
+ }
+
+ const updates = {
+ is_active: true,
+ delay_days: delayDays,
+ updatedById: currentUser.id,
+ [config.connectedField]: true,
+ [config.connectedAtField]: new Date(),
+ [config.accountField]: accountReference || business[config.accountField],
+ [config.tokenField]: business[config.tokenField] || generateWebhookToken(),
+ };
+
+ if (businessName) {
+ updates.name = businessName;
+ }
+
+ if (reviewLink) {
+ updates.google_review_link = reviewLink;
+ }
+
+ await business.update(updates);
+
+ const refreshedBusiness = await db.businesses.findByPk(business.id);
+ return serializeBusiness(req, refreshedBusiness);
+}
+
+async function rotateWebhookToken(currentUser, businessId, provider, req) {
+ const config = getProviderConfig(provider);
+ const business = await db.businesses.findOne({ where: { id: businessId, createdById: currentUser.id } });
+
+ if (!business) {
+ throw httpError('Business not found for this account.', 404);
+ }
+
+ await business.update({
+ [config.tokenField]: generateWebhookToken(),
+ updatedById: currentUser.id,
+ });
+
+ const refreshedBusiness = await db.businesses.findByPk(business.id);
+ return serializeBusiness(req, refreshedBusiness);
+}
+
+async function createCustomerFromPayment(payment, business, transaction) {
+ const ownerId = getOwnerId(business);
+ const email = normalizeEmail(payment.email);
+
+ if (!EMAIL_PATTERN.test(email)) {
+ return null;
+ }
+
+ const config = getProviderConfig(payment.provider);
+ const [customer] = await db.customers.findOrCreate({
+ where: { email, createdById: ownerId },
+ defaults: {
+ email,
+ name: payment.customerName || null,
+ phone: payment.phone || null,
+ contact_status: 'active',
+ last_transaction_at: payment.paidAt,
+ businessId: business.id,
+ [config.customerReferenceField]: payment.customerReference || null,
+ createdById: ownerId,
+ updatedById: ownerId,
+ },
+ transaction,
+ });
+
+ await customer.update({
+ name: payment.customerName || customer.name,
+ phone: payment.phone || customer.phone,
+ contact_status: customer.contact_status || 'active',
+ last_transaction_at: payment.paidAt,
+ businessId: business.id,
+ [config.customerReferenceField]: payment.customerReference || customer[config.customerReferenceField],
+ updatedById: ownerId,
+ }, { transaction });
+
+ return customer;
+}
+
+async function createTransactionFromPayment(payment, business, customer, transaction) {
+ const ownerId = getOwnerId(business);
+ const config = getProviderConfig(payment.provider);
+
+ if (payment.paymentReference) {
+ const existingTransaction = await db.transactions.findOne({
+ where: {
+ businessId: business.id,
+ [config.paymentReferenceField]: payment.paymentReference,
+ },
+ transaction,
+ });
+
+ if (existingTransaction) {
+ return { transactionRecord: existingTransaction, duplicate: true };
+ }
+ }
+
+ const transactionRecord = await db.transactions.create({
+ payment_provider: payment.provider,
+ stripe_payment_reference: payment.provider === 'stripe' ? payment.paymentReference || null : null,
+ square_payment_reference: payment.provider === 'square' ? payment.paymentReference || null : null,
+ paypal_payment_reference: payment.provider === 'paypal' ? payment.paymentReference || null : null,
+ provider_event_reference: payment.eventReference || null,
+ amount: payment.amount,
+ currency: payment.currency || null,
+ payment_status: payment.isPaymentSuccess ? 'succeeded' : 'pending',
+ paid_at: payment.paidAt,
+ description: payment.description || null,
+ receipt_email: payment.email || null,
+ businessId: business.id,
+ customerId: customer?.id || null,
+ createdById: ownerId,
+ updatedById: ownerId,
+ }, { transaction });
+
+ return { transactionRecord, duplicate: false };
+}
+
+async function createReviewRequestFromPayment(payment, business, customer, transactionRecord, transaction) {
+ const ownerId = getOwnerId(business);
+ const reviewLink = getReviewLink(business);
+
+ if (!payment.isPaymentSuccess) {
+ return null;
+ }
+
+ if (!customer) {
+ return null;
+ }
+
+ if (!reviewLink) {
+ return null;
+ }
+
+ const delayDays = Math.max(0, Number(business.delay_days) || 0);
+ const scheduledFor = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
+ const businessName = business.name || 'our business';
+ const customerName = customer.name || payment.customerName || 'there';
+ const replacements = { businessName, customerName, reviewLink };
+ const emailSubject = renderTemplate(
+ business.email_subject_template || `How was your experience with {businessName}?`,
+ replacements,
+ ) || `How was your experience with ${businessName}?`;
+ const emailBody = renderTemplate(business.email_body_template, replacements) || buildEmailBody(customerName, businessName, reviewLink);
+
+ return db.review_requests.create({
+ status: 'pending',
+ scheduled_for: scheduledFor,
+ email_subject: emailSubject,
+ email_body: emailBody,
+ review_link: reviewLink,
+ tracking_token: crypto.randomBytes(18).toString('hex'),
+ businessId: business.id,
+ customerId: customer.id,
+ transactionId: transactionRecord.id,
+ createdById: ownerId,
+ updatedById: ownerId,
+ }, { transaction });
+}
+
+async function processPaymentWebhook(providerName, businessId, secretToken, payload) {
+ const { provider, ...config } = getProviderConfig(providerName);
+ const business = await db.businesses.findByPk(businessId);
+
+ if (!business) {
+ throw httpError('Webhook business was not found.', 404);
+ }
+
+ if (!business[config.tokenField] || business[config.tokenField] !== secretToken) {
+ throw httpError('Webhook token is invalid for this business and provider.', 403);
+ }
+
+ const payment = {
+ provider,
+ ...normalizePaymentEvent(provider, payload || {}),
+ };
+ const ownerId = getOwnerId(business);
+ const existingEvent = payment.eventReference ? await db.stripe_events.findOne({
+ where: {
+ businessId: business.id,
+ provider,
+ stripe_event_reference: payment.eventReference,
+ },
+ }) : null;
+
+ if (existingEvent?.processed) {
+ return {
+ duplicate: true,
+ processed: true,
+ eventId: existingEvent.id,
+ message: 'Duplicate webhook event ignored because it was already processed.',
+ };
+ }
+
+ const eventLog = existingEvent || await db.stripe_events.create({
+ businessId: business.id,
+ stripe_event_reference: payment.eventReference || null,
+ provider,
+ provider_event_type: payment.providerEventType,
+ event_type: payment.normalizedEventType,
+ received_at: new Date(),
+ processed: false,
+ payload_json: JSON.stringify(payload || {}),
+ createdById: ownerId,
+ updatedById: ownerId,
+ });
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ await business.update({
+ [config.connectedField]: true,
+ [config.connectedAtField]: business[config.connectedAtField] || new Date(),
+ }, { transaction });
+
+ const customer = await createCustomerFromPayment(payment, business, transaction);
+ const { transactionRecord, duplicate } = await createTransactionFromPayment(payment, business, customer, transaction);
+ const reviewRequest = duplicate ? null : await createReviewRequestFromPayment(
+ payment,
+ business,
+ customer,
+ transactionRecord,
+ transaction,
+ );
+ let processingError = null;
+
+ if (payment.isPaymentSuccess && !customer) {
+ processingError = 'Payment was saved, but no customer email was present, so no review request was queued.';
+ }
+
+ if (payment.isPaymentSuccess && customer && !reviewRequest && !duplicate) {
+ processingError = 'Payment was saved, but the business has no review link, so no review request was queued.';
+ }
+
+ await eventLog.update({
+ processed: true,
+ processed_at: new Date(),
+ processing_error: processingError,
+ updatedById: ownerId,
+ }, { transaction });
+
+ await transaction.commit();
+
+ return {
+ duplicate,
+ processed: true,
+ provider,
+ eventId: eventLog.id,
+ transactionId: transactionRecord.id,
+ customerId: customer?.id || null,
+ reviewRequestId: reviewRequest?.id || null,
+ message: processingError || (reviewRequest ? 'Payment webhook processed and review request queued.' : 'Payment webhook processed.'),
+ };
+ } catch (error) {
+ await transaction.rollback();
+ await eventLog.update({
+ processed: false,
+ processing_error: error.message,
+ updatedById: ownerId,
+ });
+ throw error;
+ }
+}
+
+module.exports = {
+ PROVIDERS,
+ buildEmailBody,
+ connectProvider,
+ generateWebhookToken,
+ getProviderConfig,
+ getWebhookUrl,
+ listConnectorBusinesses,
+ processPaymentWebhook,
+ rotateWebhookToken,
+ serializeBusiness,
+};
diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx
index ab64778..9d4f84d 100644
--- a/frontend/src/components/AsideMenuLayer.tsx
+++ b/frontend/src/components/AsideMenuLayer.tsx
@@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
- ReviewFlow
+ Review Flow
diff --git a/frontend/src/components/Customers/configureCustomersCols.tsx b/frontend/src/components/Customers/configureCustomersCols.tsx
index 0d07b21..10011f5 100644
--- a/frontend/src/components/Customers/configureCustomersCols.tsx
+++ b/frontend/src/components/Customers/configureCustomersCols.tsx
@@ -152,7 +152,7 @@ export const loadColumns = async (
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
- new Date(params.row.last_transaction_at),
+ params.row.last_transaction_at ? new Date(params.row.last_transaction_at) : null,
},
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
new file mode 100644
index 0000000..eab9f67
--- /dev/null
+++ b/frontend/src/components/ReviewFlow/PaymentProviderConnectors.tsx
@@ -0,0 +1,735 @@
+import {
+ mdiAlertCircleOutline,
+ mdiCheckCircleOutline,
+ mdiContentCopy,
+ mdiCreditCardOutline,
+ mdiRefresh,
+ mdiWebhook,
+} from '@mdi/js';
+import axios from 'axios';
+import React, { FormEvent, useEffect, useMemo, useState } from 'react';
+import BaseButton from '../BaseButton';
+import CardBox from '../CardBox';
+import FormField from '../FormField';
+
+export interface ProviderConnector {
+ key: 'stripe' | 'square' | 'paypal' | string;
+ label: string;
+ connected: boolean;
+ connected_at?: string | null;
+ account_reference?: string;
+ webhook_token?: string;
+ webhook_token_last4?: string;
+ webhook_url?: string;
+}
+
+export interface ConnectorBusiness {
+ id: string;
+ name?: string;
+ google_review_link?: string;
+ delay_days?: number;
+ providers: ProviderConnector[];
+}
+
+export interface ConnectorFormValues {
+ provider: string;
+ businessName: string;
+ reviewLink: string;
+ delayDays: string;
+ accountReference: string;
+}
+
+interface PaymentProviderConnectorsProps {
+ className?: string;
+ eyebrow?: string;
+ title?: string;
+ description?: string;
+ onConnected?: (
+ business: ConnectorBusiness,
+ connectorForm: ConnectorFormValues,
+ ) => void | Promise;
+}
+
+const connectorDefaults: ConnectorFormValues = {
+ provider: 'stripe',
+ businessName: 'Review Flow Studio',
+ reviewLink: 'https://g.page/r/example/review',
+ delayDays: '7',
+ accountReference: '',
+};
+
+const providerOptions = [
+ {
+ key: 'stripe',
+ label: 'Stripe',
+ description: 'Connect card and checkout payments from Stripe.',
+ },
+ {
+ key: 'paypal',
+ label: 'PayPal',
+ description: 'Connect completed PayPal captures and sales.',
+ },
+ {
+ key: 'square',
+ label: 'Square',
+ description: 'Connect Square payment notifications.',
+ },
+];
+
+const providerInstructions: Record = {
+ stripe: [
+ 'Stripe Dashboard → Developers → Webhooks → Add endpoint.',
+ 'Paste this Review Flow webhook URL as the endpoint URL.',
+ 'Send checkout.session.completed, payment_intent.succeeded, and charge.succeeded events.',
+ ],
+ square: [
+ 'Square Developer Dashboard → Webhooks → Create subscription.',
+ 'Paste this Review Flow webhook URL as the notification URL.',
+ 'Send payment.created and payment.updated events so completed payments queue reviews.',
+ ],
+ paypal: [
+ 'PayPal Developer Dashboard → Webhooks → Add webhook.',
+ 'Paste this Review Flow webhook URL as the webhook URL.',
+ 'Send PAYMENT.CAPTURE.COMPLETED or PAYMENT.SALE.COMPLETED events.',
+ ],
+};
+
+const providerSetupDetails: Record<
+ string,
+ {
+ dashboardPath: string;
+ requiredEvents: string[];
+ steps: string[];
+ testTip: string;
+ }
+> = {
+ stripe: {
+ dashboardPath:
+ 'Stripe Dashboard → Developers → Webhooks → Add endpoint or Create event destination',
+ requiredEvents: [
+ 'checkout.session.completed',
+ 'payment_intent.succeeded',
+ 'charge.succeeded',
+ ],
+ steps: [
+ 'Select Stripe in Review Flow, enter the business name, review link, delay days, and click Connect Stripe.',
+ 'Copy the generated Review Flow webhook URL from the connected account card.',
+ 'In Stripe, create a new webhook endpoint and paste the copied URL into the endpoint URL field.',
+ 'Choose your account events unless you are intentionally configuring a Stripe Connect platform flow.',
+ 'Add the required successful payment event types listed below, then save the endpoint.',
+ 'Send a Stripe test webhook or make a test payment, then return here and click Refresh connectors.',
+ ],
+ testTip:
+ 'A successful Stripe delivery should show a 2xx response and create a payment event in Review Flow.',
+ },
+ square: {
+ dashboardPath:
+ 'Square Developer Console → Application → Webhooks → Subscriptions → Add subscription',
+ requiredEvents: ['payment.created', 'payment.updated'],
+ steps: [
+ 'Select Square in Review Flow, enter the business name, review link, delay days, and click Connect Square.',
+ 'Copy the generated Review Flow webhook URL from the connected account card.',
+ 'In Square, open your application, create a webhook subscription, and paste the copied URL as the notification URL.',
+ 'Select the payment events listed below so completed payments can be converted into review requests.',
+ 'Save the subscription and keep any Square signature details private for future verification hardening.',
+ 'Use a Square test payment or webhook test delivery, then return here and click Refresh connectors.',
+ ],
+ testTip:
+ 'Square payments queue reviews only when the payment status is completed and a customer email is available.',
+ },
+ paypal: {
+ dashboardPath:
+ 'PayPal Developer Dashboard → Apps & Credentials → REST app → Webhooks → Add webhook',
+ requiredEvents: [
+ 'PAYMENT.CAPTURE.COMPLETED',
+ 'PAYMENT.SALE.COMPLETED',
+ 'CHECKOUT.ORDER.COMPLETED',
+ ],
+ steps: [
+ 'Select PayPal in Review Flow, enter the business name, review link, delay days, and click Connect PayPal.',
+ 'Copy the generated Review Flow webhook URL from the connected account card.',
+ 'In PayPal, open the REST app that receives your payments and add a new webhook.',
+ 'Paste the copied URL into the webhook URL field and subscribe to the completed payment events listed below.',
+ 'Save the webhook, then confirm it is attached to the same PayPal app used by your checkout flow.',
+ 'Run a sandbox checkout or webhook simulator event, then return here and click Refresh connectors.',
+ ],
+ testTip:
+ 'A completed PayPal capture, sale, or checkout order should appear as a payment event before a review request is queued.',
+ },
+};
+
+const providerGradient: Record = {
+ stripe: 'from-indigo-600 to-violet-600',
+ square: 'from-emerald-600 to-teal-600',
+ paypal: 'from-sky-600 to-blue-700',
+};
+
+function formatDate(value?: string | null) {
+ if (!value) return 'Not scheduled';
+
+ return new Intl.DateTimeFormat('en', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(new Date(value));
+}
+
+function hasAuthToken() {
+ return (
+ typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
+ );
+}
+
+function isUnauthorizedError(error: unknown) {
+ return axios.isAxiosError(error) && error.response?.status === 401;
+}
+
+export default function PaymentProviderConnectors({
+ className = '',
+ eyebrow = 'Payment webhooks',
+ title = 'Connect Stripe, PayPal, and Square',
+ description = 'Connect each payment provider once. Successful payment webhooks can create customers, save transactions, and queue review requests automatically.',
+ onConnected,
+}: PaymentProviderConnectorsProps) {
+ const [connectorForm, setConnectorForm] =
+ useState(connectorDefaults);
+ const [connectors, setConnectors] = useState([]);
+ const [isConnectorLoading, setIsConnectorLoading] = useState(true);
+ const [isConnectorSubmitting, setIsConnectorSubmitting] = useState(false);
+ const [connectorMessage, setConnectorMessage] = useState('');
+ const [error, setError] = useState('');
+ const [copiedUrl, setCopiedUrl] = useState('');
+ const [isClientReady, setIsClientReady] = useState(false);
+
+ const selectedProvider =
+ providerOptions.find(
+ (provider) => provider.key === connectorForm.provider,
+ ) || providerOptions[0];
+
+ const connectorPreviewDate = useMemo(() => {
+ if (!isClientReady) return 'after the selected delay';
+
+ const days = Math.max(0, Number(connectorForm.delayDays) || 0);
+ return formatDate(
+ new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
+ );
+ }, [connectorForm.delayDays, isClientReady]);
+
+ const providerSummary = useMemo(() => {
+ const providers = connectors.flatMap(
+ (business) => business.providers || [],
+ );
+ const connectedCount = providers.filter(
+ (provider) => provider.connected,
+ ).length;
+
+ return {
+ connectedCount,
+ totalCount: providers.length || providerOptions.length,
+ };
+ }, [connectors]);
+
+ const updateConnectorForm = (
+ key: keyof ConnectorFormValues,
+ value: string,
+ ) => {
+ setConnectorForm((current) => ({ ...current, [key]: value }));
+ };
+
+ const loadConnectors = async () => {
+ setIsConnectorLoading(true);
+ try {
+ const response = await axios.get('/reviewflow/connectors');
+ setConnectors(response.data.businesses || []);
+ setError('');
+ } catch (requestError) {
+ if (!isUnauthorizedError(requestError)) {
+ console.error(
+ 'Failed to load payment webhook connectors:',
+ requestError,
+ );
+ setError(
+ 'Could not load your payment connectors. Please refresh or try again.',
+ );
+ }
+ } finally {
+ setIsConnectorLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ setIsClientReady(true);
+
+ if (!hasAuthToken()) {
+ setIsConnectorLoading(false);
+ return;
+ }
+
+ loadConnectors();
+ }, []);
+
+ const handleConnectorSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setIsConnectorSubmitting(true);
+ setConnectorMessage('');
+ setError('');
+
+ try {
+ const response = await axios.post('/reviewflow/connectors', {
+ ...connectorForm,
+ delayDays: Number(connectorForm.delayDays),
+ });
+ const business = response.data.business as ConnectorBusiness;
+ setConnectorMessage(
+ `${selectedProvider.label} is connected for ${business.name}. Copy the secure webhook URL below into your ${selectedProvider.label} dashboard.`,
+ );
+
+ await loadConnectors();
+
+ if (onConnected) {
+ try {
+ await onConnected(business, connectorForm);
+ } catch (refreshError) {
+ console.error(
+ 'Payment connector post-connect refresh failed:',
+ refreshError,
+ );
+ setError(
+ 'The connection was created, but the dashboard refresh failed. Please refresh the page to see the latest data.',
+ );
+ }
+ }
+ } catch (requestError) {
+ console.error(
+ 'Failed to connect payment webhook provider:',
+ requestError,
+ );
+ if (axios.isAxiosError(requestError) && requestError.response?.data) {
+ setError(String(requestError.response.data));
+ } else {
+ setError(
+ 'Could not connect this payment provider. Please check the fields and try again.',
+ );
+ }
+ } finally {
+ setIsConnectorSubmitting(false);
+ }
+ };
+
+ const rotateWebhookToken = async (businessId: string, provider: string) => {
+ setConnectorMessage('');
+ setError('');
+
+ try {
+ await axios.post(
+ `/reviewflow/connectors/${businessId}/${provider}/rotate`,
+ );
+ setConnectorMessage(
+ `${provider.toUpperCase()} webhook token rotated. Update the webhook URL inside ${provider.toUpperCase()} before sending more live payments.`,
+ );
+ await loadConnectors();
+ } catch (requestError) {
+ console.error('Failed to rotate payment webhook token:', requestError);
+ setError('Could not rotate the webhook token. Please try again.');
+ }
+ };
+
+ const copyWebhookUrl = async (url?: string) => {
+ if (!url) return;
+
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopiedUrl(url);
+ setConnectorMessage(
+ 'Webhook URL copied. Paste it into the matching payment provider dashboard.',
+ );
+ window.setTimeout(() => setCopiedUrl(''), 2500);
+ } catch (requestError) {
+ console.error('Failed to copy webhook URL:', requestError);
+ setError(
+ 'Could not copy the webhook URL. You can still select and copy it manually.',
+ );
+ }
+ };
+
+ return (
+
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+
+ Secure connection note
+
+ Payment providers must POST to a public webhook URL. These endpoints
+ are protected by the long secret token embedded in each generated URL.
+ Rotate a URL if it is ever exposed.
+
+
+
+ {connectorMessage && (
+
+ Connection update. {connectorMessage}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {providerOptions.map((provider) => {
+ const isSelected = connectorForm.provider === provider.key;
+
+ return (
+
updateConnectorForm('provider', provider.key)}
+ className={`rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${
+ isSelected
+ ? 'border-indigo-300 bg-indigo-50 ring-2 ring-indigo-100 dark:bg-indigo-950/30 dark:ring-indigo-900'
+ : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'
+ }`}
+ >
+
+
+ Connect {provider.label}
+
+
+ {provider.description}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ Installation guide
+
+
+ How to install payment webhooks
+
+
+ First connect a provider above to generate the secure Review Flow
+ webhook URL. Then follow the matching provider instructions below
+ and use the copied URL in that provider dashboard.
+
+
+
+
+ {providerOptions.map((provider) => {
+ const setup = providerSetupDetails[provider.key];
+
+ return (
+
+
+
+ {provider.label}
+
+
Webhook setup
+
+
+
+
+ Dashboard path
+
+
+ {setup.dashboardPath}
+
+
+
+
+
+ Install steps
+
+
+ {setup.steps.map((step, index) => (
+ {step}
+ ))}
+
+
+
+
+
+ Events to enable
+
+
+ {setup.requiredEvents.map((eventName) => (
+
+ {eventName}
+
+ ))}
+
+
+
+
+ Test after saving: {setup.testTip}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Connected accounts
+
+
+ {providerSummary.connectedCount} of {providerSummary.totalCount}{' '}
+ provider slots connected
+
+
+
+
+ {isConnectorLoading ? (
+
+ Loading payment connectors...
+
+ ) : connectors.length === 0 ? (
+
+
+
+ No payment providers connected yet
+
+
+ Choose Stripe, PayPal, or Square above to generate your first secure
+ webhook URL.
+
+
+ ) : (
+
+ {connectors.map((business) => (
+
+
+
+
+ {business.name}
+
+
+ Default review delay: {business.delay_days ?? 0} days
+
+
+
+
+
+ {business.providers.map((provider) => (
+
+
+
+
+
+ {provider.label}
+
+
+ Webhook receiver
+
+
+
+ {provider.connected ? 'Connected' : 'Not connected'}
+
+
+
+
+
+
+ Webhook URL
+
+
+ {provider.webhook_url ||
+ 'Connect this provider to reveal its secure webhook endpoint.'}
+
+
+
+ copyWebhookUrl(provider.webhook_url)}
+ />
+
+ rotateWebhookToken(business.id, provider.key)
+ }
+ />
+
+
+
+ Setup steps
+
+
+ {(providerInstructions[provider.key] || []).map(
+ (instruction) => (
+ {instruction}
+ ),
+ )}
+
+ {provider.webhook_token_last4 && (
+
+ Secret token ends in: ****
+ {provider.webhook_token_last4}
+
+ )}
+
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx b/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx
index beb1c13..c391e45 100644
--- a/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx
+++ b/frontend/src/components/Stripe_events/configureStripe_eventsCols.tsx
@@ -63,9 +63,39 @@ export const loadColumns = async (
},
+ {
+ field: 'provider',
+ headerName: 'Provider',
+ flex: 1,
+ minWidth: 120,
+ filterable: false,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+
+
+ editable: hasUpdatePermission,
+
+
+ },
+
+ {
+ field: 'provider_event_type',
+ headerName: 'ProviderEventType',
+ flex: 1,
+ minWidth: 160,
+ filterable: false,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+
+
+ editable: hasUpdatePermission,
+
+
+ },
+
{
field: 'stripe_event_reference',
- headerName: 'StripeEventReference',
+ headerName: 'EventReference',
flex: 1,
minWidth: 120,
filterable: false,
diff --git a/frontend/src/components/Transactions/configureTransactionsCols.tsx b/frontend/src/components/Transactions/configureTransactionsCols.tsx
index 4dbb71c..dfa67db 100644
--- a/frontend/src/components/Transactions/configureTransactionsCols.tsx
+++ b/frontend/src/components/Transactions/configureTransactionsCols.tsx
@@ -63,9 +63,24 @@ export const loadColumns = async (
},
+ {
+ field: 'payment_provider',
+ headerName: 'Provider',
+ flex: 1,
+ minWidth: 120,
+ filterable: false,
+ headerClassName: 'datagrid--header',
+ cellClassName: 'datagrid--cell',
+
+
+ editable: hasUpdatePermission,
+
+
+ },
+
{
field: 'stripe_payment_reference',
- headerName: 'StripePaymentReference',
+ headerName: 'PaymentReference',
flex: 1,
minWidth: 120,
filterable: false,
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 06a30e2..6ea41b5 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -1,5 +1,5 @@
import * as icon from '@mdi/js';
-import { MenuAsideItem } from './interfaces'
+import { MenuAsideItem } from './interfaces';
const menuAside: MenuAsideItem[] = [
{
@@ -7,14 +7,20 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
-
+
+ {
+ href: '/reviewflow',
+ icon: icon.mdiStarOutline,
+ label: 'Review Flow',
+ },
+
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
- permissions: 'READ_USERS'
+ permissions: 'READ_USERS',
},
{
href: '/roles/roles-list',
@@ -22,7 +28,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
- permissions: 'READ_ROLES'
+ permissions: 'READ_ROLES',
},
{
href: '/permissions/permissions-list',
@@ -30,63 +36,84 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
- permissions: 'READ_PERMISSIONS'
+ permissions: 'READ_PERMISSIONS',
},
{
href: '/businesses/businesses-list',
label: 'Businesses',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_BUSINESSES'
+ icon:
+ 'mdiStore' in icon
+ ? icon['mdiStore' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_BUSINESSES',
},
{
href: '/customers/customers-list',
label: 'Customers',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_CUSTOMERS'
+ icon:
+ 'mdiAccountMultiple' in icon
+ ? icon['mdiAccountMultiple' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_CUSTOMERS',
},
{
href: '/transactions/transactions-list',
label: 'Transactions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiCreditCardOutline' in icon ? icon['mdiCreditCardOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_TRANSACTIONS'
+ icon:
+ 'mdiCreditCardOutline' in icon
+ ? icon['mdiCreditCardOutline' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_TRANSACTIONS',
},
{
href: '/review_requests/review_requests-list',
label: 'Review requests',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiEmailFastOutline' in icon ? icon['mdiEmailFastOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_REVIEW_REQUESTS'
+ icon:
+ 'mdiEmailFastOutline' in icon
+ ? icon['mdiEmailFastOutline' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_REVIEW_REQUESTS',
},
{
href: '/stripe_events/stripe_events-list',
- label: 'Stripe events',
+ label: 'Payment events',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiWebhook' in icon ? icon['mdiWebhook' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_STRIPE_EVENTS'
+ icon:
+ 'mdiWebhook' in icon
+ ? icon['mdiWebhook' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_STRIPE_EVENTS',
},
{
href: '/email_delivery_logs/email_delivery_logs-list',
label: 'Email delivery logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiEmailCheckOutline' in icon ? icon['mdiEmailCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_EMAIL_DELIVERY_LOGS'
+ icon:
+ 'mdiEmailCheckOutline' in icon
+ ? icon['mdiEmailCheckOutline' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_EMAIL_DELIVERY_LOGS',
},
{
href: '/cron_runs/cron_runs-list',
label: 'Cron runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
- permissions: 'READ_CRON_RUNS'
+ icon:
+ 'mdiClockOutline' in icon
+ ? icon['mdiClockOutline' as keyof typeof icon]
+ : (icon.mdiTable ?? icon.mdiTable),
+ permissions: 'READ_CRON_RUNS',
},
{
href: '/profile',
@@ -94,14 +121,13 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiAccountCircle,
},
-
{
href: '/api-docs',
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
- permissions: 'READ_API_DOCS'
+ permissions: 'READ_API_DOCS',
},
-]
+];
-export default menuAside
+export default menuAside;
diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx
index 8b4a58f..a06648c 100644
--- a/frontend/src/pages/_app.tsx
+++ b/frontend/src/pages/_app.tsx
@@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
- const title = 'ReviewFlow'
+ const title = 'Review Flow'
const description = "Automate review-request emails after Stripe payments with templates, scheduling, and dashboard analytics."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/40346/app-hero-20260629-021915.png"
diff --git a/frontend/src/pages/connect.tsx b/frontend/src/pages/connect.tsx
new file mode 100644
index 0000000..4235d96
--- /dev/null
+++ b/frontend/src/pages/connect.tsx
@@ -0,0 +1,69 @@
+import { mdiConnection, mdiOpenInNew, mdiWebhook } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement } from 'react';
+import BaseButton from '../components/BaseButton';
+import PaymentProviderConnectors from '../components/ReviewFlow/PaymentProviderConnectors';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../config';
+import LayoutAuthenticated from '../layouts/Authenticated';
+
+export default function ConnectPage() {
+ return (
+ <>
+
+ {getPageTitle('Connect')}
+
+
+
+
+
+
+
+
+
+
+ Payment provider setup
+
+
+ Connect Stripe, PayPal, and Square.
+
+
+ Use this page to generate secure webhook URLs for each provider.
+ Once connected, successful payments can flow into Review Flow and
+ automatically create customers, transactions, and review
+ requests.
+
+
+
+
+
+
+
How connection works
+
+ Pick a provider, enter the business review settings, then copy
+ the generated webhook URL into that provider dashboard. You can
+ rotate URLs anytime if a secret is exposed.
+
+
+
+
+
+
+
+ >
+ );
+}
+
+ConnectPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index de6b1fd..aed2939 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,161 +1,170 @@
-
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
+import { mdiArrowRight, mdiChartTimelineVariant, mdiCheckCircleOutline, mdiLogin, mdiShieldCheckOutline, mdiStarCircleOutline } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
+import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const metrics = [
+ ['7 days', 'default review delay'],
+ ['3 sources', 'Stripe, Square, PayPal'],
+ ['4 states', 'pending, sent, clicked, reviewed'],
+];
+
+const steps = [
+ ['Capture', 'Receive Stripe, Square, or PayPal payment webhooks as soon as checkout happens.'],
+ ['Schedule', 'Create the customer, transaction, and review request automatically with your preferred delay.'],
+ ['Track', 'Follow pending, sent, clicked, and reviewed requests from one workspace.'],
+];
+
+const features = [
+ 'Business review links and templates',
+ 'Webhook-created customers and transactions',
+ 'Readable queue with message preview',
+ 'Admin CRUD and API docs still available',
+];
export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('left');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'ReviewFlow'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Review Flow')}
+
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
+
+
+
+
+
+
+ Review automation for modern local businesses
+
+
+ Ask at the perfect moment. Earn more five-star reviews.
+
+
+ Review Flow turns Stripe, Square, and PayPal payment webhooks into scheduled review requests with a clean queue, message preview, and admin controls already wired into your app.
+
+
+
+
+
+
+ {metrics.map(([value, label]) => (
+
+ ))}
+
+
+
+
+
+
+
+
Live workflow
+
Review request queued
+
+
+ pending
+
+
+
+
+
+
Customer
+
Maya Chen
+
maya@example.com
+
+
+
+
How was your experience with Review Flow Studio?
+
+ Hi Maya, thank you for choosing us. We would love to hear about your experience.
+
+
+
+
+
+
+
+
+
+ {steps.map(([title, copy], index) => (
+
+
+ {index + 1}
+
+
{title}
+
{copy}
+
+ ))}
+
+
+
+
+
+
+
First MVP slice
+
A complete thin workflow, not just a screen.
+
+ The admin workspace lets a user connect payment webhooks, receive events, create transactions and customers, queue review requests, browse recent activity, and inspect the generated message.
+
+
+
+ {features.map((feature) => (
+
+ ))}
+
+
+
+
+
+
);
}
@@ -163,4 +172,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx
index 5666163..fd4468b 100644
--- a/frontend/src/pages/login.tsx
+++ b/frontend/src/pages/login.tsx
@@ -44,7 +44,7 @@ export default function Login() {
password: 'fc6e39e3',
remember: true })
- const title = 'ReviewFlow'
+ const title = 'Review Flow'
// Fetch Pexels image/video
useEffect( () => {
diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx
index 1d0980a..ad2df90 100644
--- a/frontend/src/pages/privacy-policy.tsx
+++ b/frontend/src/pages/privacy-policy.tsx
@@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
- const title = 'ReviewFlow'
+ const title = 'Review Flow'
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {
diff --git a/frontend/src/pages/reviewflow.tsx b/frontend/src/pages/reviewflow.tsx
new file mode 100644
index 0000000..b44461f
--- /dev/null
+++ b/frontend/src/pages/reviewflow.tsx
@@ -0,0 +1,705 @@
+import {
+ mdiAccountPlusOutline,
+ mdiCreditCardOutline,
+ mdiEmailOutline,
+ mdiOpenInNew,
+ mdiRefresh,
+ mdiSend,
+ mdiStarCircleOutline,
+ mdiWebhook,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, {
+ FormEvent,
+ ReactElement,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import PaymentProviderConnectors, {
+ ConnectorFormValues,
+} from '../components/ReviewFlow/PaymentProviderConnectors';
+import BaseButton from '../components/BaseButton';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { getPageTitle } from '../config';
+
+interface ReviewBusiness {
+ id?: string;
+ name?: string;
+ google_review_link?: string;
+}
+
+interface ReviewCustomer {
+ name?: string;
+ email?: string;
+}
+
+interface ReviewTransaction {
+ id: string;
+ payment_provider?: string;
+ amount?: string | number;
+ currency?: string;
+ paid_at?: string;
+ receipt_email?: string;
+ description?: string;
+ business?: ReviewBusiness;
+ customer?: ReviewCustomer;
+}
+
+interface ReviewEvent {
+ id: string;
+ provider?: string;
+ provider_event_type?: string;
+ event_type?: string;
+ processed?: boolean;
+ processing_error?: string;
+ createdAt?: string;
+ business?: ReviewBusiness;
+}
+
+interface ReviewRequest {
+ id: string;
+ status?: string;
+ scheduled_for?: string;
+ email_subject?: string;
+ email_body?: string;
+ review_link?: string;
+ createdAt?: string;
+ business?: ReviewBusiness;
+ customer?: ReviewCustomer;
+ transaction?: ReviewTransaction;
+}
+
+interface SummaryResponse {
+ stats: {
+ pending: number;
+ sent: number;
+ clicked: number;
+ reviewed: number;
+ customers: number;
+ transactions: number;
+ paymentEvents: number;
+ };
+ requests: ReviewRequest[];
+ recentTransactions?: ReviewTransaction[];
+ recentEvents?: ReviewEvent[];
+}
+
+const defaultForm = {
+ businessName: 'Review Flow Studio',
+ reviewLink: 'https://g.page/r/example/review',
+ delayDays: '7',
+ customerName: '',
+ customerEmail: '',
+ phone: '',
+};
+
+const statusStyles: Record = {
+ pending: 'bg-amber-100 text-amber-800 ring-amber-200',
+ sent: 'bg-sky-100 text-sky-800 ring-sky-200',
+ clicked: 'bg-violet-100 text-violet-800 ring-violet-200',
+ reviewed: 'bg-emerald-100 text-emerald-800 ring-emerald-200',
+ failed: 'bg-rose-100 text-rose-800 ring-rose-200',
+};
+
+function formatDate(value?: string | null) {
+ if (!value) return 'Not scheduled';
+
+ return new Intl.DateTimeFormat('en', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ }).format(new Date(value));
+}
+
+function hasAuthToken() {
+ return (
+ typeof window !== 'undefined' && Boolean(localStorage.getItem('token'))
+ );
+}
+
+function isUnauthorizedError(error: unknown) {
+ return axios.isAxiosError(error) && error.response?.status === 401;
+}
+
+function formatAmount(amount?: string | number, currency?: string) {
+ const numericAmount = Number(amount);
+
+ if (!Number.isFinite(numericAmount)) {
+ return 'Amount pending';
+ }
+
+ return new Intl.NumberFormat('en', {
+ style: 'currency',
+ currency: currency || 'USD',
+ }).format(numericAmount);
+}
+
+export default function ReviewFlowWorkspace() {
+ const [form, setForm] = useState(defaultForm);
+ const [summary, setSummary] = useState(null);
+ const [selected, setSelected] = useState(null);
+ const [created, setCreated] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState('');
+ const [isClientReady, setIsClientReady] = useState(false);
+
+ const requests = summary?.requests ?? [];
+ const recentTransactions = summary?.recentTransactions ?? [];
+ const recentEvents = summary?.recentEvents ?? [];
+ const stats = summary?.stats ?? {
+ pending: 0,
+ sent: 0,
+ clicked: 0,
+ reviewed: 0,
+ customers: 0,
+ transactions: 0,
+ paymentEvents: 0,
+ };
+
+ const previewDate = useMemo(() => {
+ if (!isClientReady) return 'after the selected delay';
+
+ const days = Math.max(0, Number(form.delayDays) || 0);
+ return formatDate(
+ new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(),
+ );
+ }, [form.delayDays, isClientReady]);
+
+ const loadSummary = async () => {
+ setIsLoading(true);
+ try {
+ const response = await axios.get('/reviewflow/summary');
+ setSummary(response.data);
+ if (!selected && response.data.requests?.length) {
+ setSelected(response.data.requests[0]);
+ }
+ setError('');
+ } catch (requestError) {
+ if (!isUnauthorizedError(requestError)) {
+ console.error('Failed to load Review Flow summary:', requestError);
+ setError(
+ 'Could not load your review queue. Please refresh or try again.',
+ );
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ setIsClientReady(true);
+
+ if (!hasAuthToken()) {
+ setIsLoading(false);
+ return;
+ }
+
+ loadSummary();
+ }, []);
+
+ const updateForm = (key: keyof typeof defaultForm, value: string) => {
+ setForm((current) => ({ ...current, [key]: value }));
+ };
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+ setIsSubmitting(true);
+ setError('');
+ setCreated(null);
+
+ try {
+ const response = await axios.post('/reviewflow/request', {
+ ...form,
+ delayDays: Number(form.delayDays),
+ });
+ const newRequest = response.data.request;
+ setCreated(newRequest);
+ setSelected(newRequest);
+ setForm((current) => ({
+ ...current,
+ customerName: '',
+ customerEmail: '',
+ phone: '',
+ }));
+ await loadSummary();
+ } catch (requestError) {
+ console.error('Failed to create review request:', requestError);
+ if (axios.isAxiosError(requestError) && requestError.response?.data) {
+ setError(String(requestError.response.data));
+ } else {
+ setError(
+ 'Could not create the review request. Please check the fields and try again.',
+ );
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleProviderConnected = async (
+ _business: unknown,
+ connectorForm: ConnectorFormValues,
+ ) => {
+ setForm((current) => ({
+ ...current,
+ businessName: connectorForm.businessName,
+ reviewLink: connectorForm.reviewLink,
+ delayDays: connectorForm.delayDays,
+ }));
+ await loadSummary();
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Review Flow')}
+
+
+
+
+
+
+
+
+
+
+ Webhook-first workflow · payment → customer → review request
+
+
+ Let Stripe, Square, and PayPal feed the whole review engine.
+
+
+ Connect each payment provider once. Every successful payment
+ webhook can create a customer, save a transaction, and queue a
+ review request automatically.
+
+
+
+ {[
+ ['Events', stats.paymentEvents],
+ ['Payments', stats.transactions],
+ ['Pending', stats.pending],
+ ['Customers', stats.customers],
+ ['Clicked', stats.clicked],
+ ['Reviewed', stats.reviewed],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+
+ {created && (
+
+ Review request queued. {created.customer?.email} is
+ scheduled for {formatDate(created.scheduled_for)}.
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+ Manual fallback
+
+
+ Queue a review request
+
+
+ Use this when a payment did not come through a webhook, or
+ when you want to test the review queue manually.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Queue
+
+
+ Recent requests
+
+
+
+
+
+ {isLoading ? (
+
+ Loading review queue...
+
+ ) : requests.length === 0 ? (
+
+
+
+
+
+ No requests yet
+
+
+ Create one manually or send a successful provider payment
+ webhook.
+
+
+ ) : (
+
+ {requests.map((request) => {
+ const status = request.status || 'pending';
+ return (
+
setSelected(request)}
+ className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-lg ${selected?.id === request.id ? 'border-indigo-300 bg-indigo-50 dark:bg-indigo-950/30' : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
+ >
+
+
+
+ {request.customer?.name ||
+ request.customer?.email ||
+ 'Customer'}
+
+
+ {request.business?.name || 'Business'} ·{' '}
+ {formatDate(request.scheduled_for)}
+
+ {request.transaction?.payment_provider && (
+
+ From {request.transaction.payment_provider}{' '}
+ payment
+
+ )}
+
+
+ {status}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ Detail
+
+
+ Message preview
+
+ {selected ? (
+
+
+
+ To
+
+
+ {selected.customer?.email}
+
+
+
+
+ Subject
+
+
+ {selected.email_subject}
+
+
+
+ {(selected.email_body || '')
+ .split('\n')
+ .map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+
+ Review link
+
+
+ {selected.review_link}
+
+
+
+ ) : (
+
+ Select a request to preview the outgoing message.
+
+ )}
+
+
+
+
+
+
+
+
+
+ Webhook intake
+
+
+ Recent payment events
+
+
+
+
+ {recentEvents.length === 0 ? (
+
+ No provider webhooks received yet.
+
+ ) : (
+
+ {recentEvents.map((event) => (
+
+
+
+
+ {(event.provider || 'provider').toUpperCase()} ·{' '}
+ {event.provider_event_type ||
+ event.event_type ||
+ 'unknown event'}
+
+
+ {event.business?.name || 'Business'} ·{' '}
+ {formatDate(event.createdAt)}
+
+ {event.processing_error && (
+
+ {event.processing_error}
+
+ )}
+
+
+ {event.processed ? 'processed' : 'pending'}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Payments
+
+
+ Recent transactions
+
+
+
+
+ {recentTransactions.length === 0 ? (
+
+ No transactions created from webhooks yet.
+
+ ) : (
+
+ {recentTransactions.map((transaction) => (
+
+
+
+
+ {formatAmount(
+ transaction.amount,
+ transaction.currency,
+ )}
+
+
+ {transaction.payment_provider || 'provider'} ·{' '}
+ {transaction.customer?.email ||
+ transaction.receipt_email ||
+ 'No email'}{' '}
+ · {formatDate(transaction.paid_at)}
+
+
+
+ {transaction.currency || 'USD'}
+
+
+
+ ))}
+
+ )}
+
+
+
+ >
+ );
+}
+
+ReviewFlowWorkspace.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx
index f643b95..dac8f9c 100644
--- a/frontend/src/pages/terms-of-use.tsx
+++ b/frontend/src/pages/terms-of-use.tsx
@@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
export default function PrivacyPolicy() {
- const title = 'ReviewFlow';
+ const title = 'Review Flow';
const [projectUrl, setProjectUrl] = useState('');
useEffect(() => {