Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7981c715ce | ||
|
|
d173c9e9a0 | ||
|
|
e9a5ee9e90 | ||
|
|
458e67340c | ||
|
|
b12382b436 |
+9
-126
@@ -15,8 +15,15 @@
|
||||
# container can report exactly what is deployed.
|
||||
#
|
||||
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images
|
||||
# -> deploy-galactus.yml is dispatched automatically (see the
|
||||
# `deploy` job at the bottom).
|
||||
# -> deploy-on-tag.yml waits for this run to go green and then
|
||||
# dispatches deploy-galactus.yml.
|
||||
#
|
||||
# This workflow BUILDS ONLY — it never deploys. The deploy chain used to be a
|
||||
# job here, gated to tag refs, but Gitea draws every job of a workflow into the
|
||||
# run graph before it evaluates the job's `if`: a routine master build showed a
|
||||
# pending "Deploy to galactus" and looked like prod was about to be redeployed
|
||||
# off an unreleased commit. Keeping the deploy in a `on: push: tags` workflow of
|
||||
# its own makes that structurally impossible.
|
||||
|
||||
name: Build and Push Images
|
||||
|
||||
@@ -101,127 +108,3 @@ jobs:
|
||||
APP_VERSION=${{ steps.meta.outputs.version }}
|
||||
GIT_SHA=${{ github.sha }}
|
||||
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
|
||||
# Chain the PROD deploy onto a green tag build.
|
||||
#
|
||||
# `needs: build` waits for BOTH matrix legs, so api and web at this tag are
|
||||
# both in the registry before anything is deployed — deploy-galactus.yml does
|
||||
# not build, it only pulls, and a half-pushed pair is exactly the state that
|
||||
# leaves prod running one new image and one old one.
|
||||
#
|
||||
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support
|
||||
# as of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places
|
||||
# (tag, scope, bootstrap, skip_migrate). Under workflow_run every one of them
|
||||
# is the empty string, so the deploy would silently run with no tag and
|
||||
# scope != 'full'. A dispatch keeps that workflow's contract intact and keeps
|
||||
# it hand-runnable for rollbacks, which is the whole point of it.
|
||||
#
|
||||
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut
|
||||
# the chain and go back to dispatching the deploy by hand. Anything else
|
||||
# (including unset) deploys.
|
||||
deploy:
|
||||
name: Deploy to galactus
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:20-alpine
|
||||
steps:
|
||||
- name: Preflight — RELEASE_TOKEN
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "::error::Secret RELEASE_TOKEN is not set, so this build cannot"
|
||||
echo "::error::dispatch the deploy. The images ARE published — run"
|
||||
V=${GITHUB_REF#refs/tags/}
|
||||
echo "::error::'Deploy to galactus' by hand with tag=${V#v}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Dispatch deploy-galactus.yml
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
|
||||
TAG_REF: ${{ github.ref }}
|
||||
run: |
|
||||
node -e '
|
||||
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
||||
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
||||
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
|
||||
// it does not depend on how Gitea populates GITHUB_REF_NAME.
|
||||
const tagRef = process.env.TAG_REF;
|
||||
// The git tag carries the leading v; the image tag does not.
|
||||
const version = tagRef.replace(/^refs\/tags\/v?/, "");
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Every deploy-galactus run id visible right now. A dispatch is
|
||||
// only confirmed by an id that is NOT in here — a plain "is there a
|
||||
// deploy run" check is satisfied by the previous release run, and
|
||||
// would report success for a dispatch that never took.
|
||||
const deployRunIds = async () => {
|
||||
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
|
||||
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
|
||||
const body = await r.json();
|
||||
return new Set(
|
||||
(body.workflow_runs || [])
|
||||
.filter((run) => String(run.path || "").includes("deploy-galactus.yml"))
|
||||
.map((run) => run.id),
|
||||
);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
if (process.env.AUTO_DEPLOY === "false") {
|
||||
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
|
||||
console.log(`Deploy by hand with tag=${version} when ready.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const before = await deployRunIds();
|
||||
|
||||
// Dispatch against the TAG, not master: the deploy applies the
|
||||
// compose files under deploy/galactus/ from whatever ref it runs
|
||||
// on, and those must be the ones this release was cut with.
|
||||
const res = await fetch(
|
||||
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: tagRef,
|
||||
inputs: {
|
||||
tag: version,
|
||||
scope: "app",
|
||||
bootstrap: "false",
|
||||
skip_migrate: "false",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
|
||||
console.log(`::error::Images for ${version} are published. Run`);
|
||||
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// A 204 only means Gitea accepted the request. Confirm a NEW run
|
||||
// exists, the same way release.yml confirms this build started —
|
||||
// an accepted call that creates no run is the failure mode that
|
||||
// cost v1.0.3 its images.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(5_000);
|
||||
const now = await deployRunIds();
|
||||
const fresh = [...now].filter((id) => !before.has(id));
|
||||
if (fresh.length) {
|
||||
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
|
||||
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
})();
|
||||
'
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Chain the PROD deploy onto a green tag build.
|
||||
#
|
||||
# This is a SEPARATE workflow, not a job in build.yml, and the trigger is the
|
||||
# whole point: `on: push: tags` cannot fire on a push to master. When this was a
|
||||
# `deploy` job inside build.yml gated by `if: startsWith(github.ref,
|
||||
# 'refs/tags/v')`, Gitea still drew "Deploy to galactus" into the job graph of
|
||||
# every ordinary master build — the `if` is not evaluated until `needs` resolve,
|
||||
# so the job sits there looking like an imminent production deploy on a commit
|
||||
# nobody released. That is indistinguishable from a real misfire, and the only
|
||||
# safe reaction is to cancel the run, which kills the images with it.
|
||||
#
|
||||
# What it does NOT do is build. build.yml already builds and pushes both images
|
||||
# from one run; this waits for that run to go green and then dispatches
|
||||
# deploy-galactus.yml, which only pulls.
|
||||
#
|
||||
# Why wait for the build run rather than just dispatching: deploy-galactus.yml
|
||||
# pulls api and web at the same tag, and a half-pushed pair is exactly the state
|
||||
# that leaves prod running one new image and one old one. The build run turning
|
||||
# green is the signal that both are in the registry.
|
||||
#
|
||||
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support as
|
||||
# of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places (tag,
|
||||
# scope, bootstrap, skip_migrate). Under workflow_run every one of them is the
|
||||
# empty string, so the deploy would silently run with no tag and scope != 'full'.
|
||||
# A dispatch keeps that workflow's contract intact and keeps it hand-runnable for
|
||||
# rollbacks, which is the whole point of it.
|
||||
#
|
||||
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut the
|
||||
# chain and go back to dispatching the deploy by hand. Anything else (including
|
||||
# unset) deploys.
|
||||
|
||||
name: Deploy on tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to galactus
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:20-alpine
|
||||
steps:
|
||||
- name: Preflight — RELEASE_TOKEN
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "::error::Secret RELEASE_TOKEN is not set, so this cannot wait"
|
||||
echo "::error::for the build or dispatch the deploy. Once build.yml"
|
||||
V=${GITHUB_REF#refs/tags/}
|
||||
echo "::error::is green, run 'Deploy to galactus' by hand with tag=${V#v}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Wait for the tag build, then dispatch deploy-galactus.yml
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
|
||||
TAG_REF: ${{ github.ref }}
|
||||
BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
node -e '
|
||||
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
||||
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
||||
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
|
||||
// it does not depend on how Gitea populates GITHUB_REF_NAME.
|
||||
const tagRef = process.env.TAG_REF;
|
||||
const tag = tagRef.replace(/^refs\/tags\//, "");
|
||||
// The git tag carries the leading v; the image tag does not.
|
||||
const version = tag.replace(/^v/, "");
|
||||
const sha = process.env.BUILD_SHA;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const runs = async () => {
|
||||
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
|
||||
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
|
||||
return (await r.json()).workflow_runs || [];
|
||||
};
|
||||
|
||||
// The release commit and its tag are the SAME sha, and build.yml
|
||||
// skips the master run by design — so a sha match alone can latch
|
||||
// onto that skipped run and call the build green when no image was
|
||||
// ever pushed. Require the tag ref when the API reports one.
|
||||
const isTagRun = (r) => {
|
||||
const ref = r.head_branch || r.ref || "";
|
||||
return !ref || ref === tag || ref === tagRef;
|
||||
};
|
||||
|
||||
const buildRun = async () =>
|
||||
(await runs()).find(
|
||||
(r) =>
|
||||
r.head_sha === sha &&
|
||||
String(r.path || "").includes("build.yml") &&
|
||||
isTagRun(r),
|
||||
);
|
||||
|
||||
// Gitea reports a run as `status` and mirrors it into `conclusion`;
|
||||
// read whichever is populated rather than betting on one field.
|
||||
const outcome = (r) => String(r.conclusion || r.status || "").toLowerCase();
|
||||
const DONE = ["success", "failure", "cancelled", "canceled", "skipped"];
|
||||
|
||||
// Every deploy-galactus run id visible right now. A dispatch is only
|
||||
// confirmed by an id that is NOT in here — a plain "is there a deploy
|
||||
// run" check is satisfied by the PREVIOUS release run, and would
|
||||
// report success for a dispatch that never took.
|
||||
const deployRunIds = async () =>
|
||||
new Set(
|
||||
(await runs())
|
||||
.filter((r) => String(r.path || "").includes("deploy-galactus.yml"))
|
||||
.map((r) => r.id),
|
||||
);
|
||||
|
||||
(async () => {
|
||||
if (process.env.AUTO_DEPLOY === "false") {
|
||||
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
|
||||
console.log(`Deploy by hand with tag=${version} when ready.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ~20 min. A build is about 90s; the rest is queue time behind
|
||||
// other runs on a single runner.
|
||||
let run = null;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
run = await buildRun();
|
||||
if (run && DONE.includes(outcome(run))) break;
|
||||
if (!run && i === 11) {
|
||||
// Two minutes with no run at all. The post-receive hook drops
|
||||
// runs silently when it errors (this cost v1.0.3 its images),
|
||||
// so say so rather than timing out with no explanation.
|
||||
console.log(`::warning::No build.yml run for ${tag} yet after 2 min.`);
|
||||
console.log(`::warning::If the Gitea post-receive hook is broken, dispatch`);
|
||||
console.log(`::warning::"Build and Push Images" by hand with ref=${tag}.`);
|
||||
}
|
||||
await sleep(15_000);
|
||||
}
|
||||
|
||||
if (!run) {
|
||||
console.log(`::error::No build.yml run for ${tag} (${sha}) after 20 min.`);
|
||||
console.log(`::error::Dispatch "Build and Push Images" with ref=${tag} (the`);
|
||||
console.log(`::error::tag, not master), then deploy by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = outcome(run);
|
||||
if (result !== "success") {
|
||||
console.log(`::error::build.yml for ${tag} ended as "${result}" — not deploying.`);
|
||||
console.log(`::error::Fix the build, re-run it, then deploy by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`build.yml for ${tag} is green (run ${run.id}). Deploying ${version}.`);
|
||||
const before = await deployRunIds();
|
||||
|
||||
// Dispatch against the TAG, not master: the deploy applies the
|
||||
// compose files under deploy/galactus/ from whatever ref it runs
|
||||
// on, and those must be the ones this release was cut with.
|
||||
const res = await fetch(
|
||||
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ref: tagRef,
|
||||
inputs: {
|
||||
tag: version,
|
||||
scope: "app",
|
||||
bootstrap: "false",
|
||||
skip_migrate: "false",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
|
||||
console.log(`::error::Images for ${version} are published. Run`);
|
||||
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// A 204 only means Gitea accepted the request. Confirm a NEW run
|
||||
// exists — an accepted call that creates no run is the failure mode
|
||||
// that cost v1.0.3 its images.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(5_000);
|
||||
const fresh = [...(await deployRunIds())].filter((id) => !before.has(id));
|
||||
if (fresh.length) {
|
||||
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
|
||||
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
|
||||
process.exit(1);
|
||||
})();
|
||||
'
|
||||
@@ -1,14 +1,16 @@
|
||||
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||
#
|
||||
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
|
||||
# what triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and
|
||||
# `latest` image tags — and then, on a green tag build only, dispatches
|
||||
# deploy-galactus.yml with `tag=X.Y.Z scope=app` (no leading v — the git tag
|
||||
# carries the `v`, the image tag does not).
|
||||
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
|
||||
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
|
||||
# that build to go green and then dispatches deploy-galactus.yml with
|
||||
# `tag=X.Y.Z scope=app` (no leading v — the git tag carries the `v`, the image
|
||||
# tag does not). A tag is the only ref that starts either chain; pushing to
|
||||
# master builds images and stops there.
|
||||
#
|
||||
# So cutting a release DOES reach prod. To cut a version without deploying it,
|
||||
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; build.yml's `deploy`
|
||||
# job then prints the manual command instead of running it.
|
||||
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml then
|
||||
# prints the manual command instead of running it.
|
||||
#
|
||||
# Why a workflow instead of three local commands: the release commit is the one
|
||||
# thing that must be identical every time, and cutting it from a laptop is how
|
||||
@@ -270,8 +272,8 @@ jobs:
|
||||
echo "Released v${VERSION}."
|
||||
echo ""
|
||||
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
||||
echo "When both images are pushed, build.yml dispatches 'Deploy to galactus'"
|
||||
echo "automatically with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
|
||||
echo "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
|
||||
echo "'Deploy to galactus' with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
|
||||
echo ""
|
||||
echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false),"
|
||||
echo "dispatch 'Deploy to galactus' by hand with the same inputs."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import {
|
||||
BALANCE_FLOOR_JOIN,
|
||||
BALANCE_FORWARD_TYPE,
|
||||
BillingService,
|
||||
NOT_SUPERSEDED,
|
||||
} from "./billing.service";
|
||||
|
||||
/**
|
||||
* The balance floor drops rows a later BALANCE FORWARD already accounts for.
|
||||
*
|
||||
* It is worth testing because it fails silently: nothing throws, the numbers are
|
||||
* just wrong, and they were wrong for years — the whole book read +20.6M MXN in
|
||||
* credit because every customer's pre-cutover history was counted twice, once
|
||||
* inside their opening balance and once as itself.
|
||||
*/
|
||||
describe("balance floor", () => {
|
||||
describe("SQL fragments", () => {
|
||||
it("binds the type name rather than interpolating it", () => {
|
||||
// A literal would be a second place to edit if the label ever changes,
|
||||
// and this string reaches SQL from a module constant.
|
||||
expect(BALANCE_FLOOR_JOIN.values).toEqual([BALANCE_FORWARD_TYPE]);
|
||||
});
|
||||
|
||||
it("keys the floor to the row's own customer", () => {
|
||||
// Without this the derived table cross-joins and every customer inherits
|
||||
// the earliest BALANCE FORWARD in the book.
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain(
|
||||
"bfloor ON bfloor.customerId = t.customerId",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the most recent opening balance, not the first", () => {
|
||||
// A customer accumulates one BALANCE FORWARD per year. MIN would floor at
|
||||
// the oldest and leave every intervening year double-counted.
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain("MAX(bf.transactionDate)");
|
||||
expect(BALANCE_FLOOR_JOIN.sql).not.toContain("MIN(bf.transactionDate)");
|
||||
});
|
||||
|
||||
it("ignores voided opening balances when locating the floor", () => {
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain("bf.voidedAt IS NULL");
|
||||
});
|
||||
|
||||
it("is inclusive of the opening balance row itself", () => {
|
||||
// `>` instead of `>=` would drop the carried balance and understate every
|
||||
// customer by exactly that amount.
|
||||
expect(NOT_SUPERSEDED.sql).toContain("t.transactionDate >= bfloor.floorDate");
|
||||
expect(NOT_SUPERSEDED.sql).not.toMatch(/transactionDate\s*>\s*bfloor/);
|
||||
});
|
||||
|
||||
it("leaves customers with no opening balance untouched", () => {
|
||||
// NULL comparisons are never true, so without the explicit IS NULL branch
|
||||
// a customer who has no BALANCE FORWARD row loses their entire ledger.
|
||||
expect(NOT_SUPERSEDED.sql).toContain("bfloor.floorDate IS NULL");
|
||||
});
|
||||
|
||||
it("only ever references the alias the join defines", () => {
|
||||
// The predicate is useless without the join; pairing them wrongly is a
|
||||
// runtime "unknown column", so keep the alias identical in both.
|
||||
const aliases = NOT_SUPERSEDED.sql.match(/bfloor\.\w+/g) ?? [];
|
||||
expect(aliases.length).toBeGreaterThan(0);
|
||||
for (const ref of aliases) {
|
||||
expect(BALANCE_FLOOR_JOIN.sql).toContain(ref.split(".")[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement()", () => {
|
||||
/**
|
||||
* One customer means one floor date, so the statement uses a scalar lookup
|
||||
* instead of the join. Asserting on the `where` Prisma is handed is the only
|
||||
* way to see it without a database.
|
||||
*/
|
||||
function serviceWith(floor: Date | null) {
|
||||
const findMany = jest.fn().mockResolvedValue([]);
|
||||
const prisma = {
|
||||
customer: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: "c1",
|
||||
name: "CUADROS, JORGE H.",
|
||||
preferredCurrency: "USD",
|
||||
_count: { properties: 0, policies: 0 },
|
||||
}),
|
||||
},
|
||||
transaction: {
|
||||
findFirst: jest
|
||||
.fn()
|
||||
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||
findMany,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
service: new BillingService(prisma as never),
|
||||
prisma,
|
||||
findMany,
|
||||
};
|
||||
}
|
||||
|
||||
it("looks the floor up from the customer's newest opening balance", async () => {
|
||||
const { service, prisma } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
customerId: "c1",
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds the statement at the floor, inclusive", async () => {
|
||||
const floor = new Date("2026-01-01T00:00:00Z");
|
||||
const { service, findMany } = serviceWith(floor);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).toMatchObject({
|
||||
customerId: "c1",
|
||||
transactionDate: { gte: floor },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies no date bound when the customer has no opening balance", async () => {
|
||||
const { service, findMany } = serviceWith(null);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"transactionDate",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||
// The two guards answer different questions — one reproduces legacy's
|
||||
// DATOS2-only materialization, the other drops superseded history — and
|
||||
// dropping either one changes the customer's balance.
|
||||
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: NUMid 501", () => {
|
||||
/**
|
||||
* The arithmetic that exposed the bug, pinned so it cannot silently return.
|
||||
* Figures measured against the live ledger on 2026-08-05.
|
||||
*/
|
||||
const openingBalance = new Prisma.Decimal("-6732.29");
|
||||
const activitySinceOpening = new Prisma.Decimal("-7333.00");
|
||||
const preCutoverCashAlreadyInOpening = new Prisma.Decimal("3596.00");
|
||||
|
||||
it("matches the legacy portal once superseded rows are dropped", () => {
|
||||
expect(openingBalance.plus(activitySinceOpening).toFixed(2)).toBe(
|
||||
"-14065.29",
|
||||
);
|
||||
});
|
||||
|
||||
it("reproduces the wrong figure when they are not", () => {
|
||||
expect(
|
||||
openingBalance
|
||||
.plus(activitySinceOpening)
|
||||
.plus(preCutoverCashAlreadyInOpening)
|
||||
.toFixed(2),
|
||||
).toBe("-10469.29");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,24 +104,31 @@ interface BalanceRow {
|
||||
nameMissing: number;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
movements: bigint | number | string;
|
||||
movements: RawCount;
|
||||
balanceMxn: Prisma.Decimal | null;
|
||||
balanceUsd: Prisma.Decimal | null;
|
||||
chargesMxn: Prisma.Decimal | null;
|
||||
creditsMxn: Prisma.Decimal | null;
|
||||
chargesUsd: Prisma.Decimal | null;
|
||||
creditsUsd: Prisma.Decimal | null;
|
||||
utilityMovements: bigint | number | string;
|
||||
insuranceMovements: bigint | number | string;
|
||||
utilityMovements: RawCount;
|
||||
insuranceMovements: RawCount;
|
||||
lastMovement: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-query counts come back in three shapes depending on the aggregate:
|
||||
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
|
||||
* Normalize all of them before they reach the client as JSON.
|
||||
* Every shape a raw-query count can arrive in. `COUNT(*)` is a bigint,
|
||||
* `SUM(bool)` is a Prisma.Decimal, and plain numbers occur too — none of which
|
||||
* survive JSON serialization the way the client expects.
|
||||
*/
|
||||
function num(v: bigint | number | string | null | undefined): number {
|
||||
type RawCount = bigint | number | string | Prisma.Decimal;
|
||||
|
||||
/**
|
||||
* Normalizes a raw-query count before it reaches the client as JSON. A bigint
|
||||
* throws on JSON.stringify and a Decimal serializes to a *string*, so counts
|
||||
* must not be passed through untouched.
|
||||
*/
|
||||
function num(v: RawCount | null | undefined): number {
|
||||
if (v === null || v === undefined) return 0;
|
||||
return typeof v === "number" ? v : Number(v);
|
||||
}
|
||||
@@ -152,6 +159,56 @@ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||
*/
|
||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||
|
||||
/**
|
||||
* The legacy type name for a carried-forward opening balance.
|
||||
*
|
||||
* These rows are not movements. Access materialized one per customer per year,
|
||||
* dated Jan 1, holding the closing balance of everything before it — that is
|
||||
* what let the portal keep each year in its own table (`datosfreak` = current,
|
||||
* `2025`, `2024`, ...) and still show a correct running balance from a single
|
||||
* year's rows.
|
||||
*/
|
||||
export const BALANCE_FORWARD_TYPE = "BALANCE FORWARD";
|
||||
|
||||
/**
|
||||
* Per-customer date of the most recent BALANCE FORWARD row.
|
||||
*
|
||||
* Joined rather than correlated: one small derived table (1,170 rows) beats a
|
||||
* subquery evaluated per ledger row.
|
||||
*/
|
||||
export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
||||
LEFT JOIN (
|
||||
SELECT bf.customerId, MAX(bf.transactionDate) AS floorDate
|
||||
FROM transactions bf
|
||||
JOIN type_transactions bft ON bft.id = bf.typeId
|
||||
WHERE bft.nameEn = ${BALANCE_FORWARD_TYPE} AND bf.voidedAt IS NULL
|
||||
GROUP BY bf.customerId
|
||||
) bfloor ON bfloor.customerId = t.customerId`;
|
||||
|
||||
/**
|
||||
* Excludes rows a later BALANCE FORWARD already accounts for.
|
||||
*
|
||||
* WHY THIS EXISTS. The platform holds both the synthetic BALANCE FORWARD rows
|
||||
* and the real pre-cutover history they summarize, so summing a customer's
|
||||
* whole ledger counts that history twice — once inside the opening balance,
|
||||
* once as itself. NUMid 501 read -10,469.29 on the worklist against -14,065.29
|
||||
* on the customer's own statement and on the legacy portal, the gap being two
|
||||
* cash receipts from 2009 and 2012 that the 2026 opening balance had already
|
||||
* absorbed.
|
||||
*
|
||||
* The scale is what settles it: summed the old way the entire book came to
|
||||
* +20,605,447.86 MXN — the office owing its customers 20.6 million pesos.
|
||||
* Floored, it is -56,855.90, a modest net receivable. A receivables ledger
|
||||
* cannot be 20M in credit.
|
||||
*
|
||||
* Applies to BALANCES ONLY, in the same spirit as NOT_OUTSTANDING: the movement
|
||||
* browser still totals every captured row, because "how much water did we
|
||||
* capture in April" is a question about what was recorded, not about what is
|
||||
* owed. Customers with no BALANCE FORWARD row (the floor is NULL) are
|
||||
* unaffected.
|
||||
*/
|
||||
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||
|
||||
/**
|
||||
* Source tables excluded from the customer-facing statement.
|
||||
*
|
||||
@@ -402,22 +459,24 @@ export class BillingService {
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||
${having}
|
||||
${orderBy}
|
||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
||||
`;
|
||||
|
||||
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
|
||||
const counted = await this.prisma.$queryRaw<{ total: RawCount }[]>`
|
||||
SELECT COUNT(*) AS total FROM (
|
||||
SELECT c.id
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
-- Must match the page query's filters exactly, or the total disagrees
|
||||
-- with the rows. (The void exclusion was missing here before the
|
||||
-- outstanding work; a voided-only customer inflated the count.)
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id
|
||||
${having}
|
||||
) x
|
||||
@@ -458,9 +517,18 @@ export class BillingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the billing page header. */
|
||||
/**
|
||||
* Top-line figures for the billing page header.
|
||||
*
|
||||
* Two different questions live here and they use different row sets.
|
||||
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED
|
||||
* and drops rows an opening balance already accounts for. The four aggregates
|
||||
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
||||
*/
|
||||
async stats() {
|
||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
||||
const [movements, ledgerCustomers] = await Promise.all([
|
||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||
this.prisma.transaction
|
||||
.findMany({
|
||||
@@ -469,34 +537,47 @@ export class BillingService {
|
||||
select: { customerId: true },
|
||||
})
|
||||
.then((r) => r.length),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.transaction.groupBy({
|
||||
by: ["domain", "currency"],
|
||||
where: NOT_VOIDED,
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const charges = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const credits = await this.prisma.transaction.groupBy({
|
||||
by: ["currency"],
|
||||
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
||||
const byCurrency = await this.prisma.$queryRaw<
|
||||
{
|
||||
currency: string;
|
||||
net: Prisma.Decimal | null;
|
||||
count: RawCount;
|
||||
charges: Prisma.Decimal | null;
|
||||
chargeCount: RawCount;
|
||||
credits: Prisma.Decimal | null;
|
||||
creditCount: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT t.currency AS currency,
|
||||
SUM(t.amount) AS net,
|
||||
COUNT(*) AS count,
|
||||
SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END) AS charges,
|
||||
SUM(t.amount < 0) AS chargeCount,
|
||||
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) AS credits,
|
||||
SUM(t.amount > 0) AS creditCount
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.currency
|
||||
`;
|
||||
|
||||
const byDomain = await this.prisma.$queryRaw<
|
||||
{
|
||||
domain: string;
|
||||
currency: string;
|
||||
net: Prisma.Decimal | null;
|
||||
count: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT t.domain AS domain, t.currency AS currency,
|
||||
SUM(t.amount) AS net, COUNT(*) AS count
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.domain, t.currency
|
||||
`;
|
||||
|
||||
// How many customers sit on each side of the line, per currency — the
|
||||
// headline for a receivables view. Counted in SQL; a customer can be
|
||||
@@ -504,16 +585,19 @@ export class BillingService {
|
||||
const sides = await this.prisma.$queryRaw<
|
||||
{
|
||||
currency: string;
|
||||
owing: bigint | number | string;
|
||||
inCredit: bigint | number | string;
|
||||
owing: RawCount;
|
||||
inCredit: RawCount;
|
||||
}[]
|
||||
>`
|
||||
SELECT currency,
|
||||
SUM(bal < -0.005) AS owing,
|
||||
SUM(bal > 0.005) AS inCredit
|
||||
FROM (
|
||||
SELECT customerId, currency, SUM(amount) AS bal
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
||||
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||
FROM transactions t
|
||||
${BALANCE_FLOOR_JOIN}
|
||||
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED}
|
||||
GROUP BY t.customerId, t.currency
|
||||
) x
|
||||
GROUP BY currency
|
||||
`;
|
||||
@@ -534,7 +618,7 @@ export class BillingService {
|
||||
|
||||
// Customers whose ledger spans both business lines — the whole reason this
|
||||
// module is one view instead of two.
|
||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
||||
const crossLine = await this.prisma.$queryRaw<{ n: RawCount }[]>`
|
||||
SELECT COUNT(*) AS n FROM (
|
||||
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||
@@ -549,20 +633,20 @@ export class BillingService {
|
||||
lastMovement: lastRow?.transactionDate ?? null,
|
||||
byCurrency: byCurrency.map((c) => ({
|
||||
currency: c.currency,
|
||||
net: c._sum.amount,
|
||||
count: c._count._all,
|
||||
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
|
||||
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
|
||||
credits: creditMap.get(c.currency)?._sum.amount ?? null,
|
||||
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
|
||||
net: c.net,
|
||||
count: num(c.count),
|
||||
charges: c.charges,
|
||||
chargeCount: num(c.chargeCount),
|
||||
credits: c.credits,
|
||||
creditCount: num(c.creditCount),
|
||||
owing: num(sideMap.get(c.currency)?.owing),
|
||||
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
||||
})),
|
||||
byDomain: byDomain.map((d) => ({
|
||||
domain: d.domain,
|
||||
currency: d.currency,
|
||||
net: d._sum.amount,
|
||||
count: d._count._all,
|
||||
net: d.net,
|
||||
count: num(d.count),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -589,7 +673,7 @@ export class BillingService {
|
||||
});
|
||||
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
{ year: number; count: RawCount }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||
@@ -647,9 +731,32 @@ export class BillingService {
|
||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
}
|
||||
|
||||
// One customer, so the balance floor is a single date rather than the
|
||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||
// the opening balance are already inside it, and showing them would both
|
||||
// double the total and make every balanceAfter below wrong.
|
||||
//
|
||||
// This is also what stops FEE ANUAL and fee15 leaking in. They are not in
|
||||
// STATEMENT_EXCLUDED_SOURCE_TABLES — that list exists to reproduce legacy's
|
||||
// DATOS2-only `datosfreak`, and it was letting 2,092 pre-cutover fee rows
|
||||
// across 1,062 customers through, skewing the statement by -5,129,764
|
||||
// against the number those customers have been quoted for years. Dating
|
||||
// rather than source is the right test: a FEE ANUAL row *after* the opening
|
||||
// balance is a real charge and still counts.
|
||||
const floor = await this.prisma.transaction.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
voidedAt: null,
|
||||
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||
},
|
||||
orderBy: { transactionDate: "desc" },
|
||||
select: { transactionDate: true },
|
||||
});
|
||||
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||
// (which has no legacySourceTable) silently vanished from the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -149,10 +149,11 @@ def main():
|
||||
skip_cust = skip_date = skip_dupe = 0
|
||||
|
||||
def add(cid, domain, tdate, amount, currency, *, period=None, reference=None,
|
||||
typeid=None, check=None, message=None, src_db=None, src_tbl=None, legacy=None):
|
||||
typeid=None, check=None, message=None, src_db=None, src_tbl=None, legacy=None,
|
||||
outstanding=0):
|
||||
tx.append((str(uuid.uuid4()), cid, domain, typeid, tdate, period, reference,
|
||||
amount if amount is not None else Decimal(0), currency, None, check,
|
||||
message, 0, src_db, src_tbl, legacy))
|
||||
message, outstanding, src_db, src_tbl, legacy))
|
||||
|
||||
# Business key of a real cash payment. `folio` is deliberately excluded: it
|
||||
# is a per-table sequential number that collides between EFECTIVO and
|
||||
@@ -230,6 +231,16 @@ def main():
|
||||
src_db="UTILITIES", src_tbl=legacy_tbl, legacy=str(int(r["_row_num"])))
|
||||
|
||||
def billing(name, legacy_tbl):
|
||||
"""Load a DATOS2-shaped billing ledger.
|
||||
|
||||
NOPAGO is the legacy "still owed" flag. The website reads it directly —
|
||||
`account.statement.php` splits the statement on `NOPAGO = 0` vs
|
||||
`NOPAGO = 1` and renders the latter as the "Outstanding Bills Requiring
|
||||
Attention" table — so dropping it does not merely lose a column, it
|
||||
silently empties that whole section for anyone served off the platform.
|
||||
Only these three tables carry it (76 rows set in DATOS2 today); the
|
||||
EFECTIVO/FM3 cash streams have no such column and stay 0.
|
||||
"""
|
||||
nonlocal skip_cust, skip_date
|
||||
df = load("stg_utilities", name)
|
||||
for _, r in df.iterrows():
|
||||
@@ -243,7 +254,8 @@ def main():
|
||||
add(cid, "UTILITY", td, dec(r["chargecredit"], Decimal(0)), "MXN",
|
||||
period=s(r["period"]), reference=s(r["refer"]), typeid=tid,
|
||||
check=s(r["cheque"]), src_db="UTILITIES", src_tbl=legacy_tbl,
|
||||
legacy=str(int(r["_row_num"])))
|
||||
legacy=str(int(r["_row_num"])),
|
||||
outstanding=1 if s(r["nopago"]) == "1" else 0)
|
||||
|
||||
def iva():
|
||||
nonlocal skip_cust
|
||||
@@ -297,7 +309,7 @@ def main():
|
||||
if new_types:
|
||||
c.executemany("INSERT INTO type_transactions (id,nameEn,nameEs,isService) VALUES (%s,%s,%s,%s)", new_types)
|
||||
tx = [(t[0], t[1], t[2], (db_types.get(fresh_name.get(t[3])) if t[3] else None), *t[4:]) for t in tx]
|
||||
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),voidedAt=NULL", tx)
|
||||
c.executemany("INSERT INTO transactions (id,customerId,domain,typeId,transactionDate,period,reference,amount,currency,exchangeRate,checkNumber,message,outstanding,legacySourceDb,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),domain=VALUES(domain),typeId=VALUES(typeId),transactionDate=VALUES(transactionDate),period=VALUES(period),reference=VALUES(reference),amount=VALUES(amount),currency=VALUES(currency),checkNumber=VALUES(checkNumber),message=VALUES(message),outstanding=VALUES(outstanding),voidedAt=NULL", tx)
|
||||
else:
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for t in ("transactions", "type_transactions", "exchange_rates"):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.15",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"types": "generated/client/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user