This rate is the default starting price when staff click "⏱ Add Labour Line" on a service job invoice. Each line can still be edited individually.
To auto-generate Square payment links on invoices, you'll need a tiny relay endpoint that holds your Square access token (it can't be safely placed in this browser app — it would be exposed to anyone who opened the page).
Once you have a relay deployed (Cloudflare Worker, Netlify Function, AWS Lambda, etc.), paste its URL below. The app will POST { amount, currency, name, reference, buyer_email } and expect a JSON reply { url }.
Bank details appear at the bottom of every invoice. Leave fields blank to hide them.
Push caravans from Stock straight onto your Shopify store. Like Square + SMS, this needs a small Cloudflare Worker that holds your Shopify Admin API token securely. The Worker accepts POST /create and POST /update.
Domain is just a hint shown in the upload modal. The actual store URL is configured inside the Worker (as a secret).
Show example relay code (Cloudflare Worker — dual-write + payment webhook)
This Worker has two endpoints: / creates a payment link + Square Invoice + Customer when called from the app, and /webhook receives Square's payment notifications and writes the paid status straight into Supabase.
Required env vars (Worker → Settings → Variables and Secrets):
SQUARE_ACCESS_TOKEN · SQUARE_LOCATION_ID · SQUARE_WEBHOOK_SIGNATURE_KEY (NEW) · SUPABASE_URL (NEW) · SUPABASE_ANON_KEY (NEW)
Square webhook setup: Square Dashboard → Developer → Webhooks → Add subscription. URL: https://YOUR-WORKER-URL/webhook. Events: invoice.payment_made and invoice.updated. Copy the Signature Key from Square into the Worker's SQUARE_WEBHOOK_SIGNATURE_KEY env var.
// Cloudflare Worker — RVNOW Square relay (v3 — dual-write + webhook).
// Routes:
// POST / → create payment link + Square invoice + customer (called from the app)
// POST /webhook → Square sends payment events here; we update Supabase
//
// Required env vars:
// SQUARE_ACCESS_TOKEN — Production access token
// SQUARE_LOCATION_ID — e.g. LCRSG5BB3JTKJ
// SQUARE_WEBHOOK_SIGNATURE_KEY — copied from Square webhook subscription page
// SUPABASE_URL — e.g. https://psnkawqxwxcqhdjeqezc.supabase.co
// SUPABASE_ANON_KEY — same anon key the app uses
const SQ = 'https://connect.squareup.com';
const SQ_VERSION = '2025-01-22';
const cors = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
function jsonResponse(obj, status) {
return new Response(JSON.stringify(obj), {
status: status || 200,
headers: Object.assign({ 'Content-Type': 'application/json' }, cors)
});
}
async function sq(env, method, path, body) {
const r = await fetch(SQ + path, {
method,
headers: {
'Authorization': 'Bearer ' + env.SQUARE_ACCESS_TOKEN,
'Content-Type': 'application/json',
'Square-Version': SQ_VERSION
},
body: body ? JSON.stringify(body) : undefined
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const err = new Error(`Square ${path} ${r.status}: ${JSON.stringify(j.errors || j).slice(0, 600)}`);
err.path = path; err.status = r.status; err.body = j;
throw err;
}
return j;
}
function toE164AU(raw) {
if (!raw) return '';
let s = String(raw).replace(/[^\d+]/g, '');
if (!s) return '';
if (s.startsWith('+')) return s;
if (s.startsWith('0011')) return '+' + s.slice(4);
if (s.startsWith('0')) return '+61' + s.slice(1);
if (s.startsWith('61')) return '+' + s;
return '';
}
// ─────────── DUAL-WRITE (POST /) ───────────
async function upsertCustomer(env, c, log) {
if (!c) return null;
if (c.square_customer_id) return c.square_customer_id;
const email = (c.email || '').trim();
const phoneE164 = toE164AU(c.mobile);
if (email) {
try {
const j = await sq(env, 'POST', '/v2/customers/search', {
query: { filter: { email_address: { exact: email } } }
});
if (j.customers && j.customers.length > 0) { log.push('matched by email'); return j.customers[0].id; }
} catch (e) { log.push('email search failed: ' + e.message); }
}
if (phoneE164) {
try {
const j = await sq(env, 'POST', '/v2/customers/search', {
query: { filter: { phone_number: { exact: phoneE164 } } }
});
if (j.customers && j.customers.length > 0) { log.push('matched by phone'); return j.customers[0].id; }
} catch (e) { log.push('phone search failed: ' + e.message); }
}
const payload = {
idempotency_key: crypto.randomUUID(),
given_name: c.first || '',
family_name: c.last || ''
};
if (email) payload.email_address = email;
if (phoneE164) payload.phone_number = phoneE164;
if (!c.first && !c.last && c.name) payload.company_name = c.name;
try {
const j = await sq(env, 'POST', '/v2/customers', payload);
if (j.customer && j.customer.id) { log.push('created ' + j.customer.id); return j.customer.id; }
} catch (e) {
log.push('create failed: ' + e.message);
if (phoneE164 && /phone/i.test(e.message)) {
delete payload.phone_number;
payload.idempotency_key = crypto.randomUUID();
try {
const j = await sq(env, 'POST', '/v2/customers', payload);
if (j.customer && j.customer.id) { log.push('created without phone'); return j.customer.id; }
} catch (e2) { log.push('retry failed: ' + e2.message); }
}
}
return null;
}
async function createOrder(env, body, custId) {
const lineItems = (body.line_items || []).map(li => ({
name: (li.name || 'Line item').slice(0, 500),
quantity: String(li.quantity || '1'),
base_price_money: { amount: Math.round((li.base_price_inclusive || 0) * 100), currency: body.currency || 'AUD' },
applied_taxes: [{ tax_uid: 'gst' }]
}));
const order = {
location_id: env.SQUARE_LOCATION_ID,
reference_id: (body.invoice_number || '').slice(0, 40),
line_items: lineItems,
taxes: [{ uid: 'gst', name: 'GST', percentage: String(body.gst_rate || 10), scope: 'LINE_ITEM', type: 'INCLUSIVE' }]
};
if (custId) order.customer_id = custId;
if (body.discount_amount && body.discount_amount > 0) {
order.discounts = [{ name: 'Discount', amount_money: { amount: Math.round(body.discount_amount * 100), currency: body.currency || 'AUD' }, scope: 'ORDER' }];
}
const j = await sq(env, 'POST', '/v2/orders', { idempotency_key: crypto.randomUUID(), order });
return j.order;
}
async function createInvoice(env, order, custId, body) {
const inv = {
location_id: env.SQUARE_LOCATION_ID,
order_id: order.id,
delivery_method: 'SHARE_MANUALLY',
accepted_payment_methods: { card: true, square_gift_card: true, bank_account: false },
invoice_number: (body.invoice_number || '').slice(0, 191),
title: 'Caravan Service',
description: 'Service performed by Murray Bridge Caravan Centre',
payment_requests: [{ request_type: 'BALANCE', due_date: new Date().toISOString().slice(0, 10) }]
};
if (custId) inv.primary_recipient = { customer_id: custId };
const j = await sq(env, 'POST', '/v2/invoices', { idempotency_key: crypto.randomUUID(), invoice: inv });
return j.invoice;
}
async function publishInvoice(env, invoice) {
const j = await sq(env, 'POST', '/v2/invoices/' + invoice.id + '/publish', {
version: invoice.version, idempotency_key: crypto.randomUUID()
});
return j.invoice;
}
async function createPaymentLink(env, body, order) {
const totalCents = (order && order.total_money) ? order.total_money.amount : Math.round((body.amount || 0) * 100);
const j = await sq(env, 'POST', '/v2/online-checkout/payment-links', {
idempotency_key: crypto.randomUUID(),
quick_pay: {
name: body.name || 'Caravan Service',
price_money: { amount: totalCents, currency: body.currency || 'AUD' },
location_id: env.SQUARE_LOCATION_ID
},
pre_populated_data: body.buyer_email ? { buyer_email: body.buyer_email } : undefined,
payment_note: body.reference
});
return j.payment_link;
}
async function handleDualWrite(req, env) {
let body;
try { body = await req.json(); } catch { return jsonResponse({ error: 'Invalid JSON' }, 400); }
const log = [];
try {
if (!body.line_items || body.line_items.length === 0) {
const pl = await createPaymentLink(env, body, null);
return jsonResponse({ url: pl.url, id: pl.id, raw: { payment_link: pl }, log });
}
const custId = await upsertCustomer(env, body.customer, log);
let order;
try { order = await createOrder(env, body, custId); log.push('order created'); }
catch (e) { return jsonResponse({ error: 'Order creation failed: ' + e.message, log }, 500); }
let publishedInvoice = null;
try {
const draft = await createInvoice(env, order, custId, body);
log.push('invoice draft created');
publishedInvoice = await publishInvoice(env, draft);
log.push('invoice published');
} catch (e) { log.push('invoice creation failed: ' + e.message); }
let pl;
try { pl = await createPaymentLink(env, body, order); log.push('payment link created'); }
catch (e) { return jsonResponse({ error: 'Payment link failed: ' + e.message, log }, 500); }
const result = { url: pl.url, id: pl.id, log };
if (custId) result.square_customer_id = custId;
if (publishedInvoice) {
result.square_invoice_id = publishedInvoice.id;
result.square_invoice_version = publishedInvoice.version;
result.square_invoice_number = publishedInvoice.invoice_number;
result.square_invoice_url = publishedInvoice.public_url || ('https://squareup.com/dashboard/invoices/' + publishedInvoice.id);
}
result.raw = { payment_link: pl, invoice: publishedInvoice, order };
return jsonResponse(result);
} catch (e) {
return jsonResponse({ error: e.message || String(e), log }, 500);
}
}
// ─────────── WEBHOOK (POST /webhook) ───────────
// Verify Square's HMAC-SHA256 signature.
async function verifySquareSignature(rawBody, headerSig, signatureKey, fullUrl) {
if (!headerSig || !signatureKey) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(signatureKey),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
// Square signs (notification_url + raw_body)
const data = enc.encode(fullUrl + rawBody);
const sig = await crypto.subtle.sign('HMAC', key, data);
const b64 = btoa(String.fromCharCode(...new Uint8Array(sig)));
return b64 === headerSig;
}
async function findJobBySquareInvoiceId(env, invoiceId) {
const url = `${env.SUPABASE_URL}/rest/v1/service_jobs?data->>squareInvoiceId=eq.${encodeURIComponent(invoiceId)}&select=id,data&limit=1`;
const r = await fetch(url, {
headers: {
'apikey': env.SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + env.SUPABASE_ANON_KEY
}
});
if (!r.ok) throw new Error('Supabase select failed: ' + r.status);
const rows = await r.json();
return rows[0] || null;
}
async function patchJobPaid(env, rowId, existingData, paymentStatus, paidAt, amountPaid) {
// Idempotency: don't overwrite if already at same status + amount
if (existingData.paymentStatus === paymentStatus
&& existingData.paymentPaidAt === paidAt
&& Number(existingData.paymentAmountPaid || 0) === Number(amountPaid || 0)) {
return { skipped: true };
}
const newData = Object.assign({}, existingData, {
paymentStatus,
paymentPaidAt: paidAt || existingData.paymentPaidAt || null,
paymentAmountPaid: Number(amountPaid) || 0
});
const url = `${env.SUPABASE_URL}/rest/v1/service_jobs?id=eq.${rowId}`;
const r = await fetch(url, {
method: 'PATCH',
headers: {
'apikey': env.SUPABASE_ANON_KEY,
'Authorization': 'Bearer ' + env.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Prefer': 'return=minimal'
},
body: JSON.stringify({ data: newData })
});
if (!r.ok) throw new Error('Supabase patch failed: ' + r.status + ' ' + (await r.text().catch(() => '')));
return { updated: true };
}
async function handleWebhook(req, env) {
// Always 200 quickly so Square doesn't retry. Errors are logged but swallowed.
const rawBody = await req.text();
const fullUrl = new URL(req.url).toString();
const sig = req.headers.get('x-square-hmacsha256-signature');
const ok = await verifySquareSignature(rawBody, sig, env.SQUARE_WEBHOOK_SIGNATURE_KEY, fullUrl);
if (!ok) {
console.log('Square webhook signature mismatch');
return new Response('signature mismatch', { status: 401 });
}
let evt;
try { evt = JSON.parse(rawBody); } catch { return new Response('bad json', { status: 400 }); }
try {
const type = evt.type || '';
const data = evt.data || {};
const obj = (data.object && (data.object.invoice || data.object.payment)) || data.object || {};
let invoiceId = obj.id || (obj.invoice && obj.invoice.id) || (data.object && data.object.invoice && data.object.invoice.id);
let paymentStatus = null;
let paidAt = null;
let amountPaid = 0;
if (type.startsWith('invoice.')) {
const inv = (data.object && data.object.invoice) || obj;
invoiceId = inv.id;
paymentStatus = inv.status; // PAID, PARTIALLY_PAID, UNPAID, CANCELED
// Sum any payment amounts
if (inv.payment_requests && inv.payment_requests.length > 0) {
for (const pr of inv.payment_requests) {
if (pr.computed_amount_paid_money && pr.computed_amount_paid_money.amount > 0) {
amountPaid += pr.computed_amount_paid_money.amount;
paidAt = inv.updated_at || new Date().toISOString();
}
}
} else if (inv.status === 'PAID') {
paidAt = inv.updated_at || new Date().toISOString();
}
// Convert cents to dollars
amountPaid = amountPaid / 100;
}
if (!invoiceId) {
console.log('Webhook had no invoice id, skipping. Event type:', type);
return new Response('no invoice id', { status: 200 });
}
const job = await findJobBySquareInvoiceId(env, invoiceId);
if (!job) {
console.log('No app job found for invoice', invoiceId);
return new Response('job not found', { status: 200 });
}
const result = await patchJobPaid(env, job.id, job.data, paymentStatus, paidAt, amountPaid);
console.log('Webhook applied:', JSON.stringify(result), 'invoice:', invoiceId, 'amount:', amountPaid);
return new Response('ok', { status: 200 });
} catch (e) {
console.log('Webhook handler error:', e.message);
return new Response('error logged', { status: 200 });
}
}
// ─────────── ROUTER ───────────
export default {
async fetch(req, env) {
if (req.method === 'OPTIONS') return new Response(null, { headers: cors });
const url = new URL(req.url);
if (url.pathname === '/webhook') {
if (req.method !== 'POST') return new Response('POST only', { status: 405 });
return handleWebhook(req, env);
}
if (req.method !== 'POST') return jsonResponse({ error: 'POST only' }, 405);
return handleDualWrite(req, env);
}
};
📱 Social Media Image