2 Commits
Author SHA1 Message Date
siddharthd ae23b03d5d fix(trips): carry the currency and reconcile rules into the trip figure
ci / lint-test (push) Failing after 43s
ci / lint-test (pull_request) Failing after 43s
a4ab543 landed six hours ago and this branch rewrote one of the queries it
had just fixed, quietly dropping both of its guarantees.

That commit made EXCLUDE_RECONCILED_SOURCE "one fragment both sides import"
because an inlined copy is how the reconciled-row exclusion drifted out of
the analytics routes and double-counted 48 rows / $4,474.79. The trip owed
query here had hand-inlined its own copy — the fragment assumes the alias
`t` and this query used `tx`, so the path of least resistance was to
re-create exactly the divergence that was being removed. Aliased to `t` so
the fragments apply directly.

The same commit made balances count rows whose AUD value is unknown rather
than netting a foreign figure against AUD ones. The trip figure had no
equivalent — on the query where it matters most, because a trip is where
foreign rows actually live. A Europe total silently mixing EUR into AUD is
the whole failure that fix was written to prevent.

The column header still read "Share of this trip" while the number is now
net of payments, which is the same class of drift a4ab543 set out to fix.
It reads "Outstanding on this trip", carries the approx/unconverted caveat
the Shared cards use, and greys a settled zero.
2026-07-27 23:15:28 +10:00
siddharthd 689fadc8b9 feat(shared): give a payment a tab to settle
A payment has only ever recorded from, to, amount and date. That is why
the per-trip owed figure did not exist — getTripAnalytics said so where
the number should have been: "split_payments carries no trip attribution,
so a payment cannot be assigned to a trip. Settlement is a property of the
whole relationship." Every trip therefore read 100% unsettled, including
trips paid in full.

It is also why the Shared page silently drops payments under a tag filter.
With one global pool there was nothing honest to subtract, so it showed
gross splits under the same label. A tag is a view; a scope is a ledger.

The scope is a trip, not a new settlement_contexts table. trips already
has owner_id, dates and archived, and transaction_overrides.trip_id
already decides membership. A second grouping beside it would be two
unsynchronised scopes over the same rows, with no invariant saying which
governs. NULL means the ongoing household tab, which never closes.

settled answers a different question and the two must not be collapsed:
trip_id is which tab, settled is whether the obligation is still live.
Critically, a live obligation is NOT settled by flipping the flag — it is
settled by recording the payment, and the balance nets to zero on its own.
Doing both would subtract the settlement twice. So settled is written only
by the historical import, for repayments made on a platform we no longer
run, and there is deliberately no "mark settled" action.

Both owed figures now exclude settled splits and the trip figure nets its
own payments. Spend analytics (myShare/mySplitOf) deliberately still count
settled rows: my half of a 2025 grocery shop is my spend whether or not the
other half was ever repaid, and filtering them would re-inflate exactly the
figures importing settled history exists to correct.

Also drops /api/participants/[id]/balance. It had no consumers, no owner
scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth
balance implementation that disagreed with the others and would have
imported three bugs if anything had aligned to it.

getTripAnalytics had no test at all. It has five now, including the one
that matters: a household payment must not make a trip look paid. Verified
by mutation — neutering the settled filter fails three, and dropping the
trip filter on payments fails that one.
2026-07-27 23:12:05 +10:00
8 changed files with 282 additions and 61 deletions
@@ -0,0 +1,48 @@
-- Settlement scope: which tab a payment settles.
--
-- `split_payments` has carried from/to/amount/date since it was written and
-- nothing else. That is the whole reason a per-trip balance has never been
-- computable — `getTripAnalytics` says so in a comment where the figure should
-- be: "split_payments carries no trip attribution, so a payment cannot be
-- assigned to a trip. Settlement is a property of the whole relationship."
--
-- It is also the reason the Shared page silently drops payments the moment a
-- tag filter is applied (`getParticipantBalances`): with one global payments
-- pool there is no honest way to show a filtered balance, so it showed gross
-- splits under the same label instead. A tag is a view; a scope is a ledger.
--
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
-- already decides which transactions belong to it. A second grouping beside it
-- would be two unsynchronised scopes over the same rows — a trip could hold a
-- mix of contexts and a context could span trips, with no invariant saying
-- which one governs.
--
-- NULL means the ongoing household tab. That tab never closes, which is why
-- this is nullable rather than defaulted to some "general" row: absence is the
-- honest representation of "not attached to a trip", and it keeps every
-- existing payment correct without a backfill.
ALTER TABLE split_payments
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
COMMENT ON COLUMN split_payments.trip_id IS
'The trip this payment settles. NULL = the ongoing household tab.';
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
ON split_payments (trip_id)
WHERE trip_id IS NOT NULL;
-- `settled` answers a different question and the two must not be collapsed:
-- trip_id is *which tab*, settled is *is this obligation still live*. A
-- pre-2026 historical split is settled with no tab; a Europe split becomes
-- settled when Europe's payment lands; a household split stays unsettled and
-- open indefinitely.
--
-- Nothing writes `settled` today. The comment in queries.ts claims
-- /api/splits/settle does — that route does not exist, and the column is false
-- on all 1,279 rows, which is why every trip has always reported 100%
-- unsettled including trips paid in full.
COMMENT ON COLUMN transaction_splits.settled IS
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
+5
View File
@@ -18,6 +18,7 @@ model trips {
archived Boolean @default(false) archived Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
overrides transaction_overrides[] overrides transaction_overrides[]
payments split_payments[]
} }
model transaction_overrides { model transaction_overrides {
@@ -75,9 +76,13 @@ model split_payments {
payment_date DateTime @db.Date payment_date DateTime @db.Date
notes String? notes String?
linked_transaction_id Int? linked_transaction_id Int?
trip_id Int?
created_at DateTime @default(now()) created_at DateTime @default(now())
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id]) from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id]) to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
@@index([trip_id])
} }
model tags { model tags {
+1
View File
@@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) {
transactions, transactions,
statements, statements,
tags, tags,
trips,
participants participants
RESTART IDENTITY CASCADE RESTART IDENTITY CASCADE
`); `);
+107 -1
View File
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances // Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton. // use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances } = await import("@/lib/queries"); const { getTransactions, getParticipantBalances, getTripAnalytics } = await import("@/lib/queries");
beforeEach(async () => { beforeEach(async () => {
await resetDB(pool); await resetDB(pool);
@@ -305,3 +305,109 @@ describe("getTransactions — order provenance for the description sub-line", ()
expect(row.order_platform).toBeNull(); expect(row.order_platform).toBeNull();
}); });
}); });
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
describe("getParticipantBalances — settled", () => {
it("excludes a settled split from what is owed", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(0);
});
it("still counts an unsettled split alongside a settled one", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
[settledTx, otherId, liveTx]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
// Only the live split counts: 50% of 40.
expect(Number(bob!.total_owed)).toBeCloseTo(20);
});
});
describe("getTripAnalytics — per-trip settlement", () => {
async function seedTrip(ownerId: number, otherId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name, start_date, end_date)
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
return tripId;
}
it("reports the gross share before any payment", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("nets off a payment scoped to that trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', $3)`,
[otherId, ownerId, tripId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(40);
});
// The point of the whole scope column: settling the household tab must not
// make a trip look paid. Before trip_id existed there was one global pool and
// this distinction could not be expressed.
it("ignores a household payment when reporting the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
[otherId, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("drops a settled split from the trip figure", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
});
});
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
interface BalanceRow {
participant_id: number;
name: string;
total_owed: number;
transaction_count: number;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const rows = await queryRaw<BalanceRow>(
`SELECT ts.participant_id, p.name,
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
COUNT(*)::int as transaction_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
JOIN participants p ON p.id = ts.participant_id
WHERE ts.participant_id = $1 AND ts.settled = false
GROUP BY ts.participant_id, p.name`,
[Number(id)]
);
return NextResponse.json(
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
);
}
+11 -2
View File
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-zinc-800"> <tr className="border-b border-zinc-800">
{["Person", "Share of this trip"].map((h) => ( {["Person", "Outstanding on this trip"].map((h) => (
<th <th
key={h} key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`} className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
@@ -291,7 +291,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
{participant_splits.map((p) => ( {participant_splits.map((p) => (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0"> <tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td> <td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td> <td className="px-5 py-3 text-right tabular-nums font-mono">
<span className={Math.abs(Number(p.owed)) < 0.005 ? "text-zinc-500" : ""}>
${Number(p.owed).toFixed(2)}
</span>
{p.unconverted_count > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {p.unconverted_count} unconverted
</span>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+39
View File
@@ -171,3 +171,42 @@ export const SPEND_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE}) WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
ELSE (${SPEND_BASE}) ELSE (${SPEND_BASE})
END`; END`;
/**
* A split that still counts towards what someone owes.
*
* This is the line between the two questions the same table answers, and
* conflating them is what made three different "owed" figures disagree:
*
* - **Owed** — what is still outstanding between two people. Must apply this.
* - **Spend** — what a purchase cost me. Must NOT apply this.
*
* A settled split is still a real expense: my half of a 2025 grocery shop is my
* spend whether or not the other half was ever repaid. Filtering settled rows
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
* importing settled history exists to correct.
*
* `settled` marks obligations discharged OUTSIDE this app — imported
* SplitMyExpenses history, whose repayments happened on a platform we no longer
* run and which therefore has no `split_payments` row here.
*
* A live obligation is NOT settled by flipping this. It is settled by recording
* the payment, and the balance nets to zero on its own. Doing both would
* subtract the settlement twice — the splits leave the sum AND the payment is
* deducted — driving the balance negative by the amount repaid. So there is
* deliberately no "mark settled" action anywhere: settling up is recording a
* payment, and this column is only ever written by the historical import.
*
* Assumes the `transaction_splits` alias is `ts`.
*/
export const ACTIVE_OBLIGATION = `ts.settled = false`;
/**
* The tab a split belongs to: its transaction's trip, else the household.
*
* Membership already lives on `transaction_overrides.trip_id`, so this is a
* read of existing data rather than a new grouping key. Assumes an
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
* override row has no trip, which is the common case and means household).
*/
export const SPLIT_SCOPE = `o.trip_id`;
+71 -23
View File
@@ -1,5 +1,5 @@
import { queryRaw } from "./db"; import { queryRaw } from "./db";
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED } from "./analytics-sql"; import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN } from "./analytics-sql";
export interface RoutePointRow { export interface RoutePointRow {
label: string; label: string;
@@ -448,6 +448,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
// Payments settle the total relationship between two people, not a specific tag. // Payments settle the total relationship between two people, not a specific tag.
// Only subtract payments when viewing the unfiltered total; with a tag filter // Only subtract payments when viewing the unfiltered total; with a tag filter
// active, show the raw split amount for that tag context only. // active, show the raw split amount for that tag context only.
//
// That asymmetry is a symptom, not a design: a tag is a view and has no
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
// does have payments (`split_payments.trip_id`, migration 0022), which is why
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
// The fix for the tag case is to stop showing a balance there, not to invent
// one — see docs/shared-expenses-design.md.
const paymentsJoin = tagIds?.length ? "" : ` const paymentsJoin = tagIds?.length ? "" : `
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -484,6 +491,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1 WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
AND ${EXCLUDE_RECONCILED_SOURCE} AND ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${tagFilter} ${tagFilter}
UNION ALL UNION ALL
@@ -498,6 +506,7 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
LEFT JOIN statements s ON s.id = t.statement_id LEFT JOIN statements s ON s.id = t.statement_id
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1 WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
AND ${EXCLUDE_RECONCILED_SOURCE} AND ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${tagFilter} ${tagFilter}
) splits ON splits.pid = p.id ) splits ON splits.pid = p.id
${paymentsJoin} ${paymentsJoin}
@@ -825,7 +834,14 @@ export interface TripAnalytics {
daily_spend: { date: string; amount: number }[]; daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[]; top_merchants: { merchant: string; amount: number; count: number }[];
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[]; tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: { participant_id: number; name: string; owed: number }[]; participant_splits: {
participant_id: number;
name: string;
/** Their share of this trip, net of payments scoped to it. */
owed: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
}[];
} }
export async function getTrips(ownerId: number): Promise<TripRow[]> { export async function getTrips(ownerId: number): Promise<TripRow[]> {
@@ -929,27 +945,59 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
ORDER BY 4 DESC ORDER BY 4 DESC
`, [tripId]), `, [tripId]),
// No settled/unsettled breakdown here. It was computed from // Owed per participant for THIS trip, net of payments made against it.
// transaction_splits.settled, which only /api/splits/settle writes and //
// nothing in the UI calls — so it is false on all 673 splits and every trip // This was gross splits, with a comment explaining why it could not be
// reported 100% unsettled, including trips paid in full. A real per-trip // anything better: `split_payments` carried no trip attribution, so a
// figure is not computable either: split_payments carries no trip // payment could not be assigned to a trip and every trip reported 100%
// attribution, so a payment cannot be assigned to a trip. Settlement is a // unsettled including trips paid in full. `split_payments.trip_id`
// property of the whole relationship until settlement contexts exist // (migration 0022) closes that, so the figure is now real.
// (see docs/shared-expenses-design.md). //
queryRaw<{ participant_id: number; name: string; owed: number }>(` // Three exclusions, all load-bearing:
SELECT // - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
p.id AS participant_id, // rather than its original gross.
p.name, // - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed // superseded. The trip queries never applied it, so a reconciled trip
FROM transaction_overrides o // expense was counted twice here.
JOIN transactions tx ON tx.id = o.transaction_id // - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
JOIN transaction_splits ts ON ts.transaction_id = tx.id // way getParticipantBalances does. They are still summed (at their
JOIN participants p ON p.id = ts.participant_id // native figure), so a non-zero count means this total is approximate
WHERE o.trip_id = $1 // and the UI has to say so. A trip is where foreign rows actually live,
AND tx.transaction_type IN ('debit','fee','interest') // so netting a EUR figure against AUD ones silently is most likely to
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment') // bite exactly here.
GROUP BY p.id //
// `transactions` is aliased `t` so the shared fragments apply directly —
// they assume that alias, and hand-inlining a copy is what let the
// reconciled-row exclusion drift out of the analytics routes to begin with.
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
WITH owed AS (
SELECT ts.participant_id AS pid,
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1
AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY ts.participant_id
),
paid AS (
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1
GROUP BY sp.from_participant_id
)
SELECT p.id AS participant_id, p.name,
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
COALESCE(owed.unconverted, 0)::int AS unconverted_count
FROM participants p
LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
ORDER BY 3 DESC ORDER BY 3 DESC
`, [tripId]), `, [tripId]),
]); ]);