Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,28 @@ DB_LEAK_THRESHOLD_MS=30000
# ── Webhook Signature Verification ────────────────────────────────────────────
# HMAC-SHA256 signing key used to sign and verify outbound/inbound webhooks.
# Rotate with POST /api/v1/webhooks/rotate-key (overlap window: 24h)
# REQUIRED in production: at least 16 random characters. Startup fails if unset
# or left at the example value, because a known key lets anyone forge signatures.
WEBHOOK_SIGNING_KEY=change-me-to-a-long-random-secret

# ── Security Secrets (required in production) ─────────────────────────────────
# The following secrets have no safe fallback. In production the process refuses
# to start without them; outside production an ephemeral value is generated and
# a warning is logged (which means values will not survive a restart).

# Pepper for API key digests (>=16 chars). Without it a leaked key table can be
# attacked offline; with it, a leaked table is useless on its own.
API_KEY_HASH_PEPPER=

# HMAC key for opaque pagination cursors (>=16 chars). Cursors carry a sort
# field that is interpolated into SQL, so a known key would allow forging one.
CURSOR_HMAC_SECRET=

# Set to `true` ONLY when an authenticating proxy sits in front of this process
# and strips `x-subscription-tier` from inbound requests. Otherwise rate-limit
# tiers are resolved from the server-side record for each API key.
TRUST_TIER_HEADER=false

# ── Zapier Integration ─────────────────────────────────────────────────────────
# No server-side secrets required — merchants authenticate with their API keys.
# Set this to restrict which origins can reach the Zapier endpoints.
Expand Down
31 changes: 27 additions & 4 deletions backend/billing/domain/InvoiceGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ export interface GeneratedInvoice {
generatedAt: string;
}

/** Rounds to minor units, mapping non-finite input to zero. */
function roundMoney(amount: number): number {
if (!Number.isFinite(amount)) return 0;
return Math.round((amount + Number.EPSILON * Math.sign(amount)) * 100) / 100;
}

/** Constrains `value` to `[min, max]`, tolerating non-finite input. */
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}

export class InvoiceGenerator {
/**
* Generate a unique invoice number.
Expand All @@ -69,10 +81,21 @@ export class InvoiceGenerator {
* Calculate totals from line items and tax rate.
*/
calculateTotals(items: InvoiceLineItem[], taxRate: number, discount: number = 0) {
const subtotal = items.reduce((sum, item) => sum + item.total, 0);
const taxableAmount = subtotal - discount;
const taxAmount = Math.round(taxableAmount * taxRate * 100) / 100;
const total = Math.round((taxableAmount + taxAmount) * 100) / 100;
const rawSubtotal = items.reduce((sum, item) => sum + item.total, 0);
const subtotal = roundMoney(rawSubtotal);

// A discount may not exceed the subtotal. Without this clamp a discount
// larger than the invoice produced a negative tax base and a negative
// total, which downstream reconciliation reads as a credit owed rather
// than an over-discounted invoice.
const appliedDiscount = roundMoney(clamp(discount, 0, subtotal));
const taxableAmount = roundMoney(subtotal - appliedDiscount);

// `taxRate` is a decimal fraction (0.2), not basis points.
const safeTaxRate = Number.isFinite(taxRate) ? Math.max(taxRate, 0) : 0;
const taxAmount = roundMoney(taxableAmount * safeTaxRate);
const total = roundMoney(Math.max(taxableAmount + taxAmount, 0));

return { subtotal, taxAmount, total };
}

Expand Down
1 change: 1 addition & 0 deletions backend/billing/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
export type {
RevenueSchedule,
RevenueScheduleEntry,
EntryRecognitionProgress,
RecognitionJournalEntry,
RevenueRecognitionRepository,
RecognitionJobMetrics,
Expand Down
98 changes: 92 additions & 6 deletions backend/billing/jobs/monthlyRevenueRecognitionJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface RevenueScheduleEntry {
periodEnd: number; // Unix ms
recognisedAmount: number;
isRecognised: boolean;
/**
* Cumulative amount already recognised for this entry across all prior
* runs. Optional so schedules persisted before progress tracking existed
* still load; treat a missing value as 0.
*/
recognisedToDate?: number;
}

export interface RevenueSchedule {
Expand All @@ -42,6 +48,12 @@ export interface RecognitionJournalEntry {
type: 'scheduled' | 'accelerated' | 'partial';
}

/** Per-entry cumulative recognition progress recorded by a run. */
export interface EntryRecognitionProgress {
periodStart: number;
recognisedToDate: number;
}

export interface RevenueRecognitionRepository {
/** Return all schedules where at least one entry is not yet recognised. */
getPendingSchedules(): Promise<RevenueSchedule[]>;
Expand All @@ -54,6 +66,19 @@ export interface RevenueRecognitionRepository {
): Promise<void>;
/** Mark individual schedule entries as recognised. */
markEntriesRecognised(subscriptionId: string, entryPeriodStarts: number[]): Promise<void>;
/**
* Persist cumulative per-entry recognition progress.
*
* Optional. Without it a partially-elapsed entry cannot record how much of
* itself it has already recognised, and the next run re-recognises the whole
* elapsed portion — inflating recognised revenue on every run. When the
* method is absent the job degrades to recognising each entry only once,
* at its full amount, which is slower but never over-counts.
*/
recordEntryProgress?(
subscriptionId: string,
progress: EntryRecognitionProgress[]
): Promise<void>;
}

export interface RecognitionJobMetrics {
Expand All @@ -70,6 +95,12 @@ function nextId(): string {
return `rev-${Date.now().toString(36)}-${(++_seq).toString(36)}`;
}

/** Rounds to minor units, mapping non-finite input to zero. */
function roundMoney(amount: number): number {
if (!Number.isFinite(amount)) return 0;
return Math.round((amount + Number.EPSILON * Math.sign(amount)) * 100) / 100;
}

/**
* Determine how much of a single schedule entry to recognise as of `now`.
* Returns 0 for entries that haven't started yet or amount = 0 (free trial).
Expand Down Expand Up @@ -150,13 +181,48 @@ export class MonthlyRevenueRecognitionJob {
for (const schedule of schedules) {
localMetrics.schedulesProcessed += 1;
const recognisedPeriodStarts: number[] = [];
const progressUpdates: EntryRecognitionProgress[] = [];
let totalRecognisedForSchedule = 0;

// Deferred revenue is what was contractually billed but not yet
// recognised, i.e. the schedule total minus everything recognised on
// prior runs and earlier in *this* run. Ignoring prior runs produced a
// deferred balance that grew without bound.
const previouslyRecognised = (schedule.entries ?? []).reduce(
(sum, entry) => sum + (entry.recognisedToDate ?? 0),
0
);
let recognisedSoFar = previouslyRecognised;

const canTrackProgress = typeof this.repo.recordEntryProgress === 'function';

for (const entry of schedule.entries) {
const { amount, type } = computeEntryRecognition(entry, now);
if (!type || amount <= 0) continue;
const { amount: cumulativeTarget, type } = computeEntryRecognition(entry, now);
if (!type || cumulativeTarget <= 0) continue;

// Recognise only the increment since the last run. Recognising the
// full elapsed portion again on every run is what compounded the
// misstatement day after day.
const alreadyRecognised = entry.recognisedToDate ?? 0;
const amount = canTrackProgress
? roundMoney(Math.max(cumulativeTarget - alreadyRecognised, 0))
: // Without progress tracking, only recognise an entry once its
// period has fully elapsed, so it is never recognised twice.
type === 'partial'
? 0
: roundMoney(cumulativeTarget);

if (amount <= 0) {
if (type === 'scheduled' || type === 'accelerated') {
// Already fully recognised on a prior run; close it out.
recognisedPeriodStarts.push(entry.periodStart);
}
continue;
}

const deferredBefore = schedule.totalAmount - totalRecognisedForSchedule;
const deferredBefore = roundMoney(
Math.max(schedule.totalAmount - recognisedSoFar, 0)
);
const journalEntry: RecognitionJournalEntry = {
id: nextId(),
subscriptionId: schedule.subscriptionId,
Expand All @@ -165,19 +231,33 @@ export class MonthlyRevenueRecognitionJob {
periodEnd: entry.periodEnd,
recognisedAmount: amount,
deferredBefore,
deferredAfter: deferredBefore - amount,
// Deferred can never go negative: recognising more than was
// deferred would be reported as an amount owed back to the merchant.
deferredAfter: roundMoney(Math.max(deferredBefore - amount, 0)),
createdAt: now,
type,
};

try {
await this.repo.writeJournalEntry(journalEntry);
recognisedSoFar += amount;
totalRecognisedForSchedule += amount;
localMetrics.entriesRecognised += 1;
localMetrics.totalAmountRecognised += amount;

// Only mark fully-elapsed entries as done.
if (type === 'scheduled' || type === 'accelerated') {
progressUpdates.push({
periodStart: entry.periodStart,
recognisedToDate: roundMoney(alreadyRecognised + amount),
});

// Only close out entries whose period has fully elapsed AND that
// are now fully recognised.
const recognisedTotal = roundMoney(alreadyRecognised + amount);
if (
type === 'scheduled' ||
type === 'accelerated' ||
recognisedTotal >= roundMoney(entry.recognisedAmount)
) {
recognisedPeriodStarts.push(entry.periodStart);
}
} catch (err) {
Expand All @@ -195,6 +275,12 @@ export class MonthlyRevenueRecognitionJob {
recognisedDelta: totalRecognisedForSchedule,
deferredDelta: -totalRecognisedForSchedule,
});
if (progressUpdates.length > 0 && canTrackProgress) {
await this.repo.recordEntryProgress!(
schedule.subscriptionId,
progressUpdates
);
}
if (recognisedPeriodStarts.length > 0) {
await this.repo.markEntriesRecognised(
schedule.subscriptionId,
Expand Down
8 changes: 7 additions & 1 deletion backend/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ export function createGateway(options: GatewayOptions = {}): Application {

app.disable('x-powered-by');

app.use(express.json({ limit: '10mb' }));
// Reduced from the previous blanket 10mb. A JSON control-plane body has no
// legitimate need to be that large, and the old limit let a single
// unauthenticated request buffer 10 MB per connection into memory.
app.use(express.json({ limit: '256kb' }));

// ── Response envelope header ──────────────────────────────────────────
app.use((_req: Request, res: Response, next: NextFunction) => {
res.setHeader(API_VERSION_HEADER, API_VERSION_VALUE);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
// The gateway previously set no caching or referrer policy at all.
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('X-DNS-Prefetch-Control', 'off');
next();
});

Expand Down
14 changes: 12 additions & 2 deletions backend/integrations/zapier/ZapierIntegrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import crypto from 'crypto';
import { validateOutboundUrl } from '../../shared/security/outboundUrlGuard';

// ── Types ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -293,12 +294,17 @@ export class ZapierIntegrationService {
// ── Hook Subscription Management ──────────────────────────────────────────

registerHook(input: RegisterHookInput): ZapierHookSubscription {
// Re-validated at the service boundary, not only in the router: hooks can
// also be registered programmatically, and every stored `targetUrl` is
// later fetched server-side.
const target = validateOutboundUrl(input.targetUrl);

const id = this.generateId('zhook');
const now = new Date().toISOString();
const hook: ZapierHookSubscription = {
id,
merchantId: input.merchantId,
targetUrl: input.targetUrl,
targetUrl: target.toString(),
event: input.event,
signingSecret: this.generateSigningSecret(),
active: true,
Expand Down Expand Up @@ -380,7 +386,11 @@ export class ZapierIntegrationService {
const signature = this.signPayload(body, hook.signingSecret);

try {
const response = await fetchImpl(hook.targetUrl, {
// Re-validate at send time. A hook persisted before this guard existed
// would otherwise remain an SSRF vector forever.
const target = validateOutboundUrl(hook.targetUrl);

const response = await fetchImpl(target.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down
19 changes: 18 additions & 1 deletion backend/integrations/zapier/zapierRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import {
type RegisterHookInput,
type ZapierTriggerEvent,
} from './ZapierIntegrationService';
import {
validateOutboundUrl,
} from '../../shared/security/outboundUrlGuard';

function asyncHandler(fn: (req: Request, res: Response) => Promise<void>) {
return (req: Request, res: Response): void => {
Expand Down Expand Up @@ -122,9 +125,23 @@ export function createZapierRouter(service: ZapierIntegrationService): Router {
return;
}

// The registered URL is fetched server-side on every event delivery, so
// an unvalidated value here is an SSRF primitive: it would let a caller
// point the platform at cloud metadata, loopback or RFC1918 services.
let normalizedTarget: string;
try {
normalizedTarget = validateOutboundUrl(targetUrl).toString();
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Invalid targetUrl',
});
return;
}

const input: RegisterHookInput = {
merchantId,
targetUrl,
targetUrl: normalizedTarget,
event: event as ZapierTriggerEvent,
};

Expand Down
Loading
Loading