141 lines
4.6 KiB
JavaScript
141 lines
4.6 KiB
JavaScript
/**
|
|
* Shared payment store — the single source of truth that lets the OWNER/ADMIN
|
|
* side and the CUSTOMER side talk to each other in this demo (no backend).
|
|
*
|
|
* Both invoices and transactions live in localStorage so a payment request the
|
|
* owner creates appears in the customer's Outstanding list, and a payment the
|
|
* customer makes (or the owner marks manually) shows back on the owner's
|
|
* Payment Management page. In production these reads/writes would hit the CRM
|
|
* billing API instead — the component surface would stay the same.
|
|
*/
|
|
|
|
import { INVOICES } from './invoices';
|
|
|
|
const INVOICES_KEY = 'lynkeduppro.payments.invoices';
|
|
const TXNS_KEY = 'lynkeduppro.payments.transactions';
|
|
const UPDATE_EVENT = 'payments:updated';
|
|
|
|
/**
|
|
* Default customer mobile number for the demo. The owner/admin "New Payment
|
|
* Request" form prefills this so a request lands with the current demo
|
|
* customer. In production this comes from a customer lookup instead.
|
|
*/
|
|
export const DEMO_CUSTOMER_PHONE = '214-555-2600';
|
|
|
|
/** Compare two phone numbers by digits only (ignores spaces, dashes, etc.). */
|
|
export const normalizePhone = (phone) => String(phone || '').replace(/\D/g, '');
|
|
|
|
/**
|
|
* Does this invoice belong to the given customer phone? Invoices without a
|
|
* `customerPhone` are treated as unassigned and visible to any customer (keeps
|
|
* the original seed invoices working).
|
|
*/
|
|
export const invoiceMatchesCustomer = (invoice, phone) =>
|
|
!invoice.customerPhone ||
|
|
normalizePhone(invoice.customerPhone) === normalizePhone(phone);
|
|
|
|
const read = (key, fallback) => {
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
return raw ? JSON.parse(raw) : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const write = (key, value) => {
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|
} catch {
|
|
/* localStorage unavailable (private mode) — best effort only. */
|
|
}
|
|
};
|
|
|
|
/** Notify same-tab listeners (the native `storage` event only fires cross-tab). */
|
|
const emit = () => {
|
|
try {
|
|
window.dispatchEvent(new Event(UPDATE_EVENT));
|
|
} catch {
|
|
/* non-browser / SSR — nothing to notify. */
|
|
}
|
|
};
|
|
|
|
/** Subscribe to any invoice/transaction change. Returns an unsubscribe fn. */
|
|
export const subscribe = (callback) => {
|
|
window.addEventListener(UPDATE_EVENT, callback);
|
|
window.addEventListener('storage', callback);
|
|
return () => {
|
|
window.removeEventListener(UPDATE_EVENT, callback);
|
|
window.removeEventListener('storage', callback);
|
|
};
|
|
};
|
|
|
|
/* ------------------------------- Invoices -------------------------------- */
|
|
|
|
/** Load invoices, seeding from the static sample list on first run. */
|
|
export const loadInvoices = () => {
|
|
const stored = read(INVOICES_KEY, null);
|
|
if (Array.isArray(stored)) return stored;
|
|
write(INVOICES_KEY, INVOICES);
|
|
return INVOICES;
|
|
};
|
|
|
|
export const saveInvoices = (list) => {
|
|
write(INVOICES_KEY, list);
|
|
emit();
|
|
};
|
|
|
|
export const getInvoice = (id) => loadInvoices().find((i) => i.id === id) || null;
|
|
|
|
/** Append a new invoice (payment request) and return it. */
|
|
export const addInvoice = (invoice) => {
|
|
const next = [...loadInvoices(), invoice];
|
|
saveInvoices(next);
|
|
return invoice;
|
|
};
|
|
|
|
/** Next sequential invoice id, e.g. "INV-2068". */
|
|
export const nextInvoiceId = () => {
|
|
const max = loadInvoices().reduce((m, inv) => {
|
|
const n = parseInt(String(inv.id).replace(/\D/g, ''), 10);
|
|
return Number.isFinite(n) && n > m ? n : m;
|
|
}, 2000);
|
|
return `INV-${max + 1}`;
|
|
};
|
|
|
|
/* ----------------------------- Transactions ------------------------------ */
|
|
|
|
export const loadTransactions = () => read(TXNS_KEY, []);
|
|
|
|
export const saveTransactions = (list) => {
|
|
write(TXNS_KEY, list);
|
|
emit();
|
|
};
|
|
|
|
/** Has this invoice been paid (by the customer or marked by the owner)? */
|
|
export const isInvoicePaid = (invoiceId) =>
|
|
loadTransactions().some((t) => t.invoiceId === invoiceId && t.status === 'paid');
|
|
|
|
/**
|
|
* Mark an invoice paid by writing a `paid` transaction — used both by the
|
|
* customer success flow and by the owner's manual "Mark as Paid" action.
|
|
* No-ops if a paid transaction for this invoice already exists.
|
|
*/
|
|
export const markInvoicePaid = (invoiceId, { method = 'manual' } = {}) => {
|
|
const inv = getInvoice(invoiceId);
|
|
if (!inv || isInvoicePaid(invoiceId)) return null;
|
|
|
|
const txn = {
|
|
id: `${method}-${invoiceId}-${Date.now()}`,
|
|
invoiceId,
|
|
amount: inv.amount,
|
|
currency: inv.currency,
|
|
customerEmail: null,
|
|
status: 'paid',
|
|
createdAt: new Date().toISOString(),
|
|
method,
|
|
};
|
|
saveTransactions([txn, ...loadTransactions()]);
|
|
return txn;
|
|
};
|