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
3 changes: 3 additions & 0 deletions src/lib/offline/order-wizard-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export type WizardAccount = {
state: string | null;
zip: string | null;
payment_preference: string | null;
/** Optional: snapshots cached before SCO-137 don't carry these. */
organization_id?: string;
order_minimum_override?: number | string | null;
};

export type WizardLocation = {
Expand Down
28 changes: 26 additions & 2 deletions src/lib/server/ai-tools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { getGmailClient, parseMessage, buildRawEmail } from './gmail.js';
import { supabaseAdmin } from './supabase.js';
import { findMinimumViolations, blockingViolations } from './orders/order-minimum.js';
import { checkFilterColumns, omitForEntity } from './ai-query-columns.js';
import {
FULFILLMENT_STATUSES,
Expand Down Expand Up @@ -617,8 +618,24 @@ async function createOrder(
};
}

// Brand minimum (SCO-137): under the minimum the order stays a draft, unless
// the brand's own org is placing it. The reason goes back to the model so it
// can tell the user why.
let minimumReason: string | null = null;
if (finalStatus === 'submitted' && orderType === 'order') {
const total = lineRows.reduce((sum, r) => sum + r.qty * r.unit_price, 0);
const [block] = blockingViolations(
await findMinimumViolations(
supabaseAdmin,
[{ brand_id: brand.id, account_id: account.id, total_amount: total }],
ctx.organizationId
)
);
minimumReason = block?.message ?? null;
}

// --- Promote to submitted (unless the caller asked for draft) ---
if (finalStatus === 'submitted') {
if (finalStatus === 'submitted' && !minimumReason) {
const { error: statusErr } = await ctx.supabase
.from('orders')
.update({ status: 'submitted', submitted_at: new Date().toISOString() })
Expand Down Expand Up @@ -668,7 +685,14 @@ async function createOrder(
'seasons'
]
});
return { success: true, data: { ...trimmed, lines_added: lineRows.length } };
return {
success: true,
data: {
...trimmed,
lines_added: lineRows.length,
...(minimumReason ? { saved_as_draft_reason: minimumReason } : {})
}
};
}

// Fire the same email + in-app notifications that /orders/new does on submit.
Expand Down
30 changes: 30 additions & 0 deletions src/lib/server/email-intake/outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { supabaseAdmin } from '$lib/server/supabase.js';
import type { ResolvedOrder, ResolvedLine } from './resolve.js';
import { THRESHOLDS } from './resolve.js';
import type { SenderAuthVerdict } from './authentication.js';
import { findMinimumViolations, blockingViolations } from '$lib/server/orders/order-minimum.js';

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

Expand Down Expand Up @@ -85,6 +86,35 @@ export function decideOutcome(

// ── Execution ────────────────────────────────────────────────

/**
* Hold an auto-submit for review when the order is under its brand's minimum
* (SCO-137). The order still lands, as a draft, so the brand can approve it.
* The intake's own org is the actor: a brand receiving its own orders may
* submit under its minimum, so only a rep's intake is held.
*/
export async function applyOrderMinimum(
resolved: ResolvedOrder & { kind: 'resolved' },
outcome: Outcome
): Promise<Outcome> {
if (outcome.status !== 'submitted' || !resolved.brandId) return outcome;
const total = resolved.lines.reduce(
(sum, line) => sum + line.variants.reduce((s, v) => s + v.qty * v.unitPrice, 0),
0
);
const [block] = blockingViolations(
await findMinimumViolations(
supabaseAdmin,
[{ brand_id: resolved.brandId, account_id: resolved.accountId, total_amount: total }],
resolved.organizationId
)
);
if (!block) return outcome;
return {
status: 'needs_review',
reasons: [...outcome.reasons, { lineIndex: null, reason: block.message }]
};
}

/**
* Execute the outcome: create the order and update the intake record.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server/orders/load-order-prereqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export async function loadOrderPrereqs(locals: App.Locals) {
let q = supabaseAdmin
.from('accounts')
.select(
'id, business_name, contact_email, address_line1, address_line2, city, state, zip, payment_preference, payment_terms, shipping_method'
'id, organization_id, business_name, contact_email, address_line1, address_line2, city, state, zip, payment_preference, payment_terms, shipping_method, order_minimum_override'
);
if (buyerAccountIds) q = q.in('id', buyerAccountIds.length ? buyerAccountIds : ['__none__']);
else q = q.in('organization_id', visibleOrgIds).eq('is_active', true).is('archived_at', null);
Expand Down
116 changes: 116 additions & 0 deletions src/lib/server/orders/order-minimum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import { findMinimumViolations, blockingViolations } from './order-minimum';
import type { BrandCommerceSettings } from './resolve-order-settings';

function settingsFor(
brandId: string,
ownerOrgId: string,
amount: number | null
): BrandCommerceSettings {
return {
brand_id: brandId,
owner_org_id: ownerOrgId,
order_minimum_enabled: amount !== null,
order_minimum_amount: amount
} as BrandCommerceSettings;
}

// Minimal stand-in for the two lookups the helper makes.
function fakeClient(tables: Record<string, Array<Record<string, unknown>>>): SupabaseClient {
return {
from(table: string) {
return {
select() {
return {
in(_col: string, ids: string[]) {
const rows = (tables[table] ?? []).filter((r) => ids.includes(r.id as string));
return Promise.resolve({ data: rows, error: null });
}
};
}
};
}
} as unknown as SupabaseClient;
}

const client = fakeClient({
brands: [{ id: 'b1', name: 'Acme' }],
accounts: [
{ id: 'own-acct', organization_id: 'brand-org', order_minimum_override: 100 },
{ id: 'rep-acct', organization_id: 'rep-org', order_minimum_override: 0 }
]
});
const settings = new Map([['b1', settingsFor('b1', 'brand-org', 500)]]);

describe('findMinimumViolations', () => {
it('blocks a rep under the minimum, naming the brand', async () => {
const v = await findMinimumViolations(
client,
[{ brand_id: 'b1', account_id: 'rep-acct', total_amount: 380 }],
'rep-org',
settings
);
expect(v).toHaveLength(1);
expect(v[0]).toMatchObject({ shortfall: 120, minimum: 500, canOverride: false });
expect(v[0].message).toContain("Acme's $500.00 minimum");
expect(blockingViolations(v)).toHaveLength(1);
});

it('lets the brand override its own minimum', async () => {
const v = await findMinimumViolations(
client,
[{ brand_id: 'b1', account_id: 'rep-acct', total_amount: '380.00' }],
'brand-org',
settings
);
expect(v[0].canOverride).toBe(true);
expect(blockingViolations(v)).toHaveLength(0);
});

it('never lets a null actor (a buyer) override', async () => {
const v = await findMinimumViolations(
client,
[{ brand_id: 'b1', account_id: null, total_amount: 10 }],
null,
settings
);
expect(blockingViolations(v)).toHaveLength(1);
});

it("honors the brand's own account override", async () => {
const v = await findMinimumViolations(
client,
[{ brand_id: 'b1', account_id: 'own-acct', total_amount: 150 }],
'rep-org',
settings
);
expect(v).toHaveLength(0);
});

it('passes orders at or over the minimum and brands with none', async () => {
const v = await findMinimumViolations(
client,
[
{ brand_id: 'b1', account_id: null, total_amount: 500 },
{ brand_id: 'b2', account_id: null, total_amount: 1 }
],
'rep-org',
new Map([...settings, ['b2', settingsFor('b2', 'other-org', null)]])
);
expect(v).toHaveLength(0);
});

it('reports the index of each failing order', async () => {
const v = await findMinimumViolations(
client,
[
{ brand_id: 'b1', account_id: null, total_amount: 900 },
{ brand_id: 'b1', account_id: null, total_amount: 10 }
],
'rep-org',
settings
);
expect(v.map((x) => x.index)).toEqual([1]);
});
});
103 changes: 103 additions & 0 deletions src/lib/server/orders/order-minimum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import { resolveOrderSettings, type BrandCommerceSettings } from './resolve-order-settings.js';
import {
effectiveOrderMinimum,
minimumBlockedMessage,
minimumOverrideNote,
minimumShortfall,
type MinimumAccount
} from '$lib/utils/order-minimum';

export type MinimumCheckOrder = {
brand_id: string;
account_id: string | null;
total_amount: number | string | null;
};

export type MinimumViolation = {
/** Index into the orders passed in. */
index: number;
brand_id: string;
brand_name: string;
minimum: number;
shortfall: number;
/** The actor's org owns the brand, so it may submit anyway. */
canOverride: boolean;
/** What to show the actor: a block for others, a note for the brand. */
message: string;
};

/**
* Which of these orders sit under their brand's minimum (SCO-137).
*
* Takes the caller's client rather than naming one: every call site has
* already authorized the order, and some run as the service role. Pass
* `settings` when the caller has already resolved them.
*/
export async function findMinimumViolations(
client: SupabaseClient,
orders: MinimumCheckOrder[],
/**
* The acting org, or null when the actor can never override. Pass null for
* a buyer: a legacy buyer's `locals.organization` is the brand's own org,
* and a buyer must not inherit the brand's override.
*/
actorOrgId: string | null,
settings?: Map<string, BrandCommerceSettings>
): Promise<MinimumViolation[]> {
if (orders.length === 0) return [];
const brandIds = Array.from(new Set(orders.map((o) => o.brand_id)));
const resolved = settings ?? (await resolveOrderSettings(client, brandIds));

const accountIds = Array.from(
new Set(orders.map((o) => o.account_id).filter((id): id is string => Boolean(id)))
);
const [{ data: accountRows }, { data: brandRows }] = await Promise.all([
accountIds.length
? client
.from('accounts')
.select('id, organization_id, order_minimum_override')
.in('id', accountIds)
: Promise.resolve({ data: [] }),
client.from('brands').select('id, name').in('id', brandIds)
]);
const accounts = new Map(
((accountRows ?? []) as Array<{ id: string } & NonNullable<MinimumAccount>>).map((a) => [
a.id,
a
])
);
const brandNames = new Map(
((brandRows ?? []) as Array<{ id: string; name: string }>).map((b) => [b.id, b.name])
);

const violations: MinimumViolation[] = [];
orders.forEach((order, index) => {
const brandSettings = resolved.get(order.brand_id);
if (!brandSettings) return;
const account = order.account_id ? (accounts.get(order.account_id) ?? null) : null;
const minimum = effectiveOrderMinimum(brandSettings, account);
const shortfall = minimumShortfall(Number(order.total_amount ?? 0), minimum);
if (minimum === null || shortfall === null) return;

const canOverride = actorOrgId !== null && actorOrgId === brandSettings.owner_org_id;
const brandName = brandNames.get(order.brand_id) ?? 'the brand';
violations.push({
index,
brand_id: order.brand_id,
brand_name: brandName,
minimum,
shortfall,
canOverride,
message: canOverride
? minimumOverrideNote(minimum, shortfall)
: minimumBlockedMessage(brandName, minimum, shortfall)
});
});
return violations;
}

/** The violations that stop the actor from submitting. */
export function blockingViolations(violations: MinimumViolation[]): MinimumViolation[] {
return violations.filter((v) => !v.canOverride);
}
Loading
Loading