@porulle/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +1 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +5 -0
- package/dist/config/types.d.ts +17 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +16 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/staff.js +172 -0
- package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
- package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/analytics.js +24 -0
- package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/carts.js +39 -2
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +17 -1
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +17 -0
- package/dist/interfaces/rest/routes/documents.d.ts +10 -0
- package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/documents.js +40 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +98 -1
- package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/pricing.js +36 -1
- package/dist/interfaces/rest/routes/settings.d.ts +5 -0
- package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/settings.js +50 -0
- package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
- package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/shipping.js +68 -0
- package/dist/interfaces/rest/routes/tax.d.ts +5 -0
- package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/tax.js +68 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
- package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
- package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/analytics.js +33 -0
- package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/carts.js +38 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +48 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
- package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/checkout.js +4 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
- package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/documents.js +65 -0
- package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +233 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/pricing.js +70 -0
- package/dist/interfaces/rest/schemas/responses.d.ts +156 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
- package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/settings.js +79 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
- package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/shipping.js +146 -0
- package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
- package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/tax.js +132 -0
- package/dist/kernel/database/schema.d.ts +4 -0
- package/dist/kernel/database/schema.d.ts.map +1 -1
- package/dist/kernel/database/schema.js +8 -0
- package/dist/kernel/hooks/types.d.ts +1 -1
- package/dist/kernel/hooks/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +23 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +17 -2
- package/dist/modules/analytics/module.d.ts.map +1 -1
- package/dist/modules/analytics/module.js +6 -0
- package/dist/modules/analytics/reports.d.ts +58 -0
- package/dist/modules/analytics/reports.d.ts.map +1 -0
- package/dist/modules/analytics/reports.js +249 -0
- package/dist/modules/analytics/service.d.ts +10 -0
- package/dist/modules/analytics/service.d.ts.map +1 -1
- package/dist/modules/analytics/service.js +14 -0
- package/dist/modules/cart/repository/index.d.ts +7 -0
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +25 -1
- package/dist/modules/cart/schema.d.ts +17 -0
- package/dist/modules/cart/schema.d.ts.map +1 -1
- package/dist/modules/cart/schema.js +2 -0
- package/dist/modules/cart/schemas.d.ts +1 -0
- package/dist/modules/cart/schemas.d.ts.map +1 -1
- package/dist/modules/cart/schemas.js +1 -0
- package/dist/modules/cart/service.d.ts +32 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +58 -0
- package/dist/modules/catalog/entity-service.d.ts +21 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +132 -3
- package/dist/modules/catalog/schema.d.ts +34 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +5 -0
- package/dist/modules/catalog/schemas.d.ts +3 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +3 -0
- package/dist/modules/catalog/service.d.ts +36 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +6 -0
- package/dist/modules/documents/module.d.ts +7 -0
- package/dist/modules/documents/module.d.ts.map +1 -0
- package/dist/modules/documents/module.js +12 -0
- package/dist/modules/documents/pdf.d.ts +25 -0
- package/dist/modules/documents/pdf.d.ts.map +1 -0
- package/dist/modules/documents/pdf.js +80 -0
- package/dist/modules/documents/render.d.ts +32 -0
- package/dist/modules/documents/render.d.ts.map +1 -0
- package/dist/modules/documents/render.js +190 -0
- package/dist/modules/documents/repository/index.d.ts +25 -0
- package/dist/modules/documents/repository/index.d.ts.map +1 -0
- package/dist/modules/documents/repository/index.js +47 -0
- package/dist/modules/documents/schema.d.ts +214 -0
- package/dist/modules/documents/schema.d.ts.map +1 -0
- package/dist/modules/documents/schema.js +42 -0
- package/dist/modules/documents/service.d.ts +51 -0
- package/dist/modules/documents/service.d.ts.map +1 -0
- package/dist/modules/documents/service.js +119 -0
- package/dist/modules/fulfillment/service.d.ts +18 -0
- package/dist/modules/fulfillment/service.d.ts.map +1 -1
- package/dist/modules/fulfillment/service.js +85 -1
- package/dist/modules/orders/repository/index.d.ts +18 -1
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +86 -1
- package/dist/modules/orders/schema.d.ts +374 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +52 -1
- package/dist/modules/orders/service.d.ts +86 -1
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +447 -20
- package/dist/modules/pricing/repository/index.d.ts +5 -0
- package/dist/modules/pricing/repository/index.d.ts.map +1 -1
- package/dist/modules/pricing/repository/index.js +19 -0
- package/dist/modules/pricing/service.d.ts +18 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +38 -0
- package/dist/modules/settings/module.d.ts +6 -0
- package/dist/modules/settings/module.d.ts.map +1 -0
- package/dist/modules/settings/module.js +11 -0
- package/dist/modules/settings/repository/index.d.ts +16 -0
- package/dist/modules/settings/repository/index.d.ts.map +1 -0
- package/dist/modules/settings/repository/index.js +41 -0
- package/dist/modules/settings/schema.d.ts +121 -0
- package/dist/modules/settings/schema.d.ts.map +1 -0
- package/dist/modules/settings/schema.js +24 -0
- package/dist/modules/settings/service.d.ts +26 -0
- package/dist/modules/settings/service.d.ts.map +1 -0
- package/dist/modules/settings/service.js +51 -0
- package/dist/modules/shipping/calculator.d.ts +5 -0
- package/dist/modules/shipping/calculator.d.ts.map +1 -1
- package/dist/modules/shipping/calculator.js +10 -0
- package/dist/modules/shipping/module.d.ts +5 -1
- package/dist/modules/shipping/module.d.ts.map +1 -1
- package/dist/modules/shipping/module.js +4 -1
- package/dist/modules/shipping/repository/index.d.ts +27 -2
- package/dist/modules/shipping/repository/index.d.ts.map +1 -1
- package/dist/modules/shipping/repository/index.js +105 -1
- package/dist/modules/shipping/schema.d.ts +419 -0
- package/dist/modules/shipping/schema.d.ts.map +1 -0
- package/dist/modules/shipping/schema.js +56 -0
- package/dist/modules/shipping/service.d.ts +54 -0
- package/dist/modules/shipping/service.d.ts.map +1 -1
- package/dist/modules/shipping/service.js +161 -2
- package/dist/modules/tax/adapter.d.ts +13 -0
- package/dist/modules/tax/adapter.d.ts.map +1 -1
- package/dist/modules/tax/module.d.ts +5 -1
- package/dist/modules/tax/module.d.ts.map +1 -1
- package/dist/modules/tax/module.js +4 -1
- package/dist/modules/tax/repository/index.d.ts +38 -2
- package/dist/modules/tax/repository/index.d.ts.map +1 -1
- package/dist/modules/tax/repository/index.js +138 -1
- package/dist/modules/tax/schema.d.ts +355 -0
- package/dist/modules/tax/schema.d.ts.map +1 -0
- package/dist/modules/tax/schema.js +55 -0
- package/dist/modules/tax/service.d.ts +60 -1
- package/dist/modules/tax/service.d.ts.map +1 -1
- package/dist/modules/tax/service.js +227 -2
- package/dist/runtime/kernel-modules.d.ts +15 -2
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/dist/runtime/kernel-modules.js +4 -0
- package/dist/runtime/kernel-types.d.ts +5 -1
- package/dist/runtime/kernel-types.d.ts.map +1 -1
- package/dist/runtime/kernel-types.js +2 -0
- package/dist/runtime/server.js +1 -1
- package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
- package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
- package/dist/test-utils/create-plugin-test-app.js +15 -3
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +6 -0
- package/package.json +3 -3
- package/src/auth/setup.ts +1 -0
- package/src/config/defaults.ts +5 -0
- package/src/config/types.ts +17 -1
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/hooks/checkout.ts +25 -3
- package/src/index.ts +1 -0
- package/src/interfaces/rest/index.ts +12 -0
- package/src/interfaces/rest/routes/admin/staff.ts +218 -0
- package/src/interfaces/rest/routes/analytics.ts +33 -0
- package/src/interfaces/rest/routes/carts.ts +52 -2
- package/src/interfaces/rest/routes/catalog.ts +34 -0
- package/src/interfaces/rest/routes/checkout.ts +22 -0
- package/src/interfaces/rest/routes/documents.ts +61 -0
- package/src/interfaces/rest/routes/orders.ts +137 -1
- package/src/interfaces/rest/routes/pricing.ts +44 -1
- package/src/interfaces/rest/routes/settings.ts +61 -0
- package/src/interfaces/rest/routes/shipping.ts +92 -0
- package/src/interfaces/rest/routes/tax.ts +90 -0
- package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
- package/src/interfaces/rest/schemas/analytics.ts +39 -0
- package/src/interfaces/rest/schemas/carts.ts +40 -0
- package/src/interfaces/rest/schemas/catalog.ts +53 -0
- package/src/interfaces/rest/schemas/checkout.ts +4 -0
- package/src/interfaces/rest/schemas/documents.ts +72 -0
- package/src/interfaces/rest/schemas/orders.ts +257 -0
- package/src/interfaces/rest/schemas/pricing.ts +75 -0
- package/src/interfaces/rest/schemas/settings.ts +93 -0
- package/src/interfaces/rest/schemas/shipping.ts +164 -0
- package/src/interfaces/rest/schemas/tax.ts +148 -0
- package/src/kernel/database/schema.ts +12 -0
- package/src/kernel/hooks/types.ts +1 -0
- package/src/kernel/plugin/manifest.ts +38 -2
- package/src/modules/analytics/module.ts +10 -0
- package/src/modules/analytics/reports.ts +311 -0
- package/src/modules/analytics/service.ts +23 -0
- package/src/modules/cart/repository/index.ts +30 -1
- package/src/modules/cart/schema.ts +2 -0
- package/src/modules/cart/schemas.ts +1 -0
- package/src/modules/cart/service.ts +101 -0
- package/src/modules/catalog/entity-service.ts +168 -3
- package/src/modules/catalog/schema.ts +5 -0
- package/src/modules/catalog/schemas.ts +3 -0
- package/src/modules/catalog/service.ts +30 -0
- package/src/modules/documents/module.ts +19 -0
- package/src/modules/documents/pdf.ts +101 -0
- package/src/modules/documents/render.ts +231 -0
- package/src/modules/documents/repository/index.ts +68 -0
- package/src/modules/documents/schema.ts +44 -0
- package/src/modules/documents/service.ts +176 -0
- package/src/modules/fulfillment/service.ts +139 -1
- package/src/modules/orders/repository/index.ts +127 -1
- package/src/modules/orders/schema.ts +54 -1
- package/src/modules/orders/service.ts +654 -22
- package/src/modules/pricing/repository/index.ts +34 -0
- package/src/modules/pricing/service.ts +71 -0
- package/src/modules/settings/module.ts +18 -0
- package/src/modules/settings/repository/index.ts +59 -0
- package/src/modules/settings/schema.ts +25 -0
- package/src/modules/settings/service.ts +82 -0
- package/src/modules/shipping/calculator.ts +27 -0
- package/src/modules/shipping/module.ts +6 -2
- package/src/modules/shipping/repository/index.ts +136 -2
- package/src/modules/shipping/schema.ts +58 -0
- package/src/modules/shipping/service.ts +260 -1
- package/src/modules/tax/adapter.ts +9 -0
- package/src/modules/tax/module.ts +6 -2
- package/src/modules/tax/repository/index.ts +169 -2
- package/src/modules/tax/schema.ts +57 -0
- package/src/modules/tax/service.ts +313 -2
- package/src/runtime/kernel-modules.ts +4 -0
- package/src/runtime/kernel-types.ts +6 -0
- package/src/runtime/server.ts +1 -1
- package/src/test-utils/create-plugin-test-app.ts +17 -4
- package/src/test-utils/rest-api-test-utils.ts +6 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
2
|
+
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
|
+
import type {
|
|
4
|
+
DrizzleDatabase,
|
|
5
|
+
DbOrTx,
|
|
6
|
+
} from "../../../kernel/database/drizzle-db.js";
|
|
7
|
+
import { invoiceSequences, orderDocuments } from "../schema.js";
|
|
8
|
+
|
|
9
|
+
export type OrderDocument = typeof orderDocuments.$inferSelect;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Persistence for fiscal sequences and issued documents (issue #47).
|
|
13
|
+
*/
|
|
14
|
+
export class DocumentsRepository {
|
|
15
|
+
constructor(private readonly db: DrizzleDatabase) {}
|
|
16
|
+
|
|
17
|
+
private getDb(ctx?: TxContext): DbOrTx {
|
|
18
|
+
return (ctx?.tx as DbOrTx | undefined) ?? this.db;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Atomically allocates the next sequence value for (org, series).
|
|
23
|
+
* Single upsert-returning statement — safe under concurrency.
|
|
24
|
+
*/
|
|
25
|
+
async allocate(orgId: string, series: string, ctx?: TxContext): Promise<number> {
|
|
26
|
+
const db = this.getDb(ctx);
|
|
27
|
+
const rows = await db
|
|
28
|
+
.insert(invoiceSequences)
|
|
29
|
+
.values({ organizationId: orgId, series, nextValue: 2 })
|
|
30
|
+
.onConflictDoUpdate({
|
|
31
|
+
target: [invoiceSequences.organizationId, invoiceSequences.series],
|
|
32
|
+
set: {
|
|
33
|
+
nextValue: sql`${invoiceSequences.nextValue} + 1`,
|
|
34
|
+
updatedAt: new Date(),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.returning({ nextValue: invoiceSequences.nextValue });
|
|
38
|
+
// nextValue is post-increment; the allocated number is one less.
|
|
39
|
+
return rows[0]!.nextValue - 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async findDocument(
|
|
43
|
+
orgId: string,
|
|
44
|
+
orderId: string,
|
|
45
|
+
type: string,
|
|
46
|
+
ctx?: TxContext,
|
|
47
|
+
): Promise<OrderDocument | undefined> {
|
|
48
|
+
const db = this.getDb(ctx);
|
|
49
|
+
const rows = await db
|
|
50
|
+
.select()
|
|
51
|
+
.from(orderDocuments)
|
|
52
|
+
.where(and(
|
|
53
|
+
eq(orderDocuments.organizationId, orgId),
|
|
54
|
+
eq(orderDocuments.orderId, orderId),
|
|
55
|
+
eq(orderDocuments.type, type),
|
|
56
|
+
));
|
|
57
|
+
return rows[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async createDocument(
|
|
61
|
+
data: { organizationId: string; orderId: string; type: string; documentNumber: string },
|
|
62
|
+
ctx?: TxContext,
|
|
63
|
+
): Promise<OrderDocument> {
|
|
64
|
+
const db = this.getDb(ctx);
|
|
65
|
+
const rows = await db.insert(orderDocuments).values(data).returning();
|
|
66
|
+
return rows[0]!;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
|
+
import { organization } from "../../auth/auth-schema.js";
|
|
3
|
+
import { orders } from "../orders/schema.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fiscal document sequences (issue #47).
|
|
7
|
+
*
|
|
8
|
+
* Per-(org, series) counters for legally sequential invoice numbers.
|
|
9
|
+
* Allocation is a single atomic upsert-returning statement, so concurrent
|
|
10
|
+
* requests never observe the same value.
|
|
11
|
+
*/
|
|
12
|
+
export const invoiceSequences = pgTable("invoice_sequences", {
|
|
13
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
14
|
+
organizationId: text("organization_id")
|
|
15
|
+
.notNull()
|
|
16
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
17
|
+
series: text("series").notNull().default("default"),
|
|
18
|
+
// The NEXT value to hand out; allocation returns the pre-increment value.
|
|
19
|
+
nextValue: integer("next_value").notNull().default(1),
|
|
20
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
21
|
+
}, (table) => ({
|
|
22
|
+
orgSeriesUnique: uniqueIndex("invoice_sequences_org_series_unique").on(table.organizationId, table.series),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Issued documents (issue #47). One row per (org, order, type) — re-rendering
|
|
27
|
+
* an order's invoice returns the number issued the first time, so a fiscal
|
|
28
|
+
* number never changes once handed out.
|
|
29
|
+
*/
|
|
30
|
+
export const orderDocuments = pgTable("order_documents", {
|
|
31
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
32
|
+
organizationId: text("organization_id")
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => organization.id, { onDelete: "cascade" }),
|
|
35
|
+
orderId: uuid("order_id")
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => orders.id, { onDelete: "cascade" }),
|
|
38
|
+
type: text("type").notNull(), // "invoice" | "receipt"
|
|
39
|
+
documentNumber: text("document_number").notNull(),
|
|
40
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
41
|
+
}, (table) => ({
|
|
42
|
+
orgOrderTypeUnique: uniqueIndex("order_documents_org_order_type_unique").on(table.organizationId, table.orderId, table.type),
|
|
43
|
+
orgIdx: index("idx_order_documents_org").on(table.organizationId),
|
|
44
|
+
}));
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { resolveOrgId } from "../../auth/org.js";
|
|
2
|
+
import type { Actor } from "../../auth/types.js";
|
|
3
|
+
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
4
|
+
import { CommerceValidationError } from "../../kernel/errors.js";
|
|
5
|
+
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
6
|
+
import type { HydratedOrder, OrderService } from "../orders/service.js";
|
|
7
|
+
import type { SettingsService } from "../settings/service.js";
|
|
8
|
+
import { buildPdf } from "./pdf.js";
|
|
9
|
+
import {
|
|
10
|
+
invoiceHtml,
|
|
11
|
+
invoicePdfOps,
|
|
12
|
+
receiptHtml,
|
|
13
|
+
type DocumentBranding,
|
|
14
|
+
} from "./render.js";
|
|
15
|
+
import type { DocumentsRepository, OrderDocument } from "./repository/index.js";
|
|
16
|
+
|
|
17
|
+
interface DocumentsServiceDeps {
|
|
18
|
+
repository: DocumentsRepository;
|
|
19
|
+
// The shared kernel service container — orders/settings/email are resolved
|
|
20
|
+
// at call time (they may instantiate after this module).
|
|
21
|
+
services: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface EmailAdapter {
|
|
25
|
+
send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_INVOICE_PREFIX = "INV-";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Order document rendering + fiscal numbering (issue #47).
|
|
32
|
+
*/
|
|
33
|
+
export class DocumentsService {
|
|
34
|
+
private repository: DocumentsRepository;
|
|
35
|
+
private services: Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
constructor(deps: DocumentsServiceDeps) {
|
|
38
|
+
this.repository = deps.repository;
|
|
39
|
+
this.services = deps.services;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private get orders(): OrderService {
|
|
43
|
+
return this.services.orders as OrderService;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private get settings(): SettingsService {
|
|
47
|
+
return this.services.settings as SettingsService;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async brandingFor(orgId: string, ctx?: TxContext): Promise<DocumentBranding> {
|
|
51
|
+
return (await this.settings.read(orgId, "branding", ctx)) as DocumentBranding;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Issues (or returns the previously issued) fiscal invoice number for an
|
|
56
|
+
* order. Idempotent per (org, order): once handed out, the number never
|
|
57
|
+
* changes. Sequence allocation is atomic; a lost creation race falls back
|
|
58
|
+
* to the winner's document.
|
|
59
|
+
*/
|
|
60
|
+
async issueInvoiceNumber(
|
|
61
|
+
order: HydratedOrder,
|
|
62
|
+
ctx?: TxContext,
|
|
63
|
+
): Promise<OrderDocument> {
|
|
64
|
+
const orgId = order.organizationId;
|
|
65
|
+
const existing = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
|
|
66
|
+
if (existing) return existing;
|
|
67
|
+
|
|
68
|
+
const documentsSettings = await this.settings.read(orgId, "documents", ctx);
|
|
69
|
+
const prefix =
|
|
70
|
+
typeof documentsSettings.invoicePrefix === "string"
|
|
71
|
+
? documentsSettings.invoicePrefix
|
|
72
|
+
: DEFAULT_INVOICE_PREFIX;
|
|
73
|
+
|
|
74
|
+
const value = await this.repository.allocate(orgId, "invoice", ctx);
|
|
75
|
+
const documentNumber = `${prefix}${String(value).padStart(6, "0")}`;
|
|
76
|
+
try {
|
|
77
|
+
return await this.repository.createDocument(
|
|
78
|
+
{ organizationId: orgId, orderId: order.id, type: "invoice", documentNumber },
|
|
79
|
+
ctx,
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Unique (org, order, type) — a concurrent request won; use its number.
|
|
83
|
+
const winner = await this.repository.findDocument(orgId, order.id, "invoice", ctx);
|
|
84
|
+
if (winner) return winner;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async loadOrder(
|
|
90
|
+
orderId: string,
|
|
91
|
+
actor: Actor | null,
|
|
92
|
+
ctx?: TxContext,
|
|
93
|
+
): Promise<Result<HydratedOrder>> {
|
|
94
|
+
return this.orders.getById(orderId, actor, ctx);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async renderInvoiceHtml(
|
|
98
|
+
orderId: string,
|
|
99
|
+
actor: Actor | null,
|
|
100
|
+
ctx?: TxContext,
|
|
101
|
+
): Promise<Result<{ html: string; invoiceNumber: string }>> {
|
|
102
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
103
|
+
if (!order.ok) return order;
|
|
104
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
105
|
+
const doc = await this.issueInvoiceNumber(order.value, ctx);
|
|
106
|
+
return Ok({
|
|
107
|
+
html: invoiceHtml({ order: order.value, branding, invoiceNumber: doc.documentNumber }),
|
|
108
|
+
invoiceNumber: doc.documentNumber,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async renderInvoicePdf(
|
|
113
|
+
orderId: string,
|
|
114
|
+
actor: Actor | null,
|
|
115
|
+
ctx?: TxContext,
|
|
116
|
+
): Promise<Result<{ pdf: Uint8Array; invoiceNumber: string }>> {
|
|
117
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
118
|
+
if (!order.ok) return order;
|
|
119
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
120
|
+
const doc = await this.issueInvoiceNumber(order.value, ctx);
|
|
121
|
+
const pages = invoicePdfOps({
|
|
122
|
+
order: order.value,
|
|
123
|
+
branding,
|
|
124
|
+
invoiceNumber: doc.documentNumber,
|
|
125
|
+
});
|
|
126
|
+
return Ok({ pdf: buildPdf(pages), invoiceNumber: doc.documentNumber });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async renderReceiptHtml(
|
|
130
|
+
orderId: string,
|
|
131
|
+
actor: Actor | null,
|
|
132
|
+
ctx?: TxContext,
|
|
133
|
+
): Promise<Result<{ html: string }>> {
|
|
134
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
135
|
+
if (!order.ok) return order;
|
|
136
|
+
const branding = await this.brandingFor(order.value.organizationId, ctx);
|
|
137
|
+
return Ok({ html: receiptHtml({ order: order.value, branding }) });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Emails the invoice via the configured email adapter. The adapter's
|
|
142
|
+
* template contract is (template, to, data) — the rendered HTML and the
|
|
143
|
+
* fiscal number ride in `data` so template-based adapters can use either.
|
|
144
|
+
*/
|
|
145
|
+
async emailInvoice(
|
|
146
|
+
orderId: string,
|
|
147
|
+
to: string,
|
|
148
|
+
actor: Actor | null,
|
|
149
|
+
ctx?: TxContext,
|
|
150
|
+
): Promise<Result<{ sent: true; invoiceNumber: string; to: string }>> {
|
|
151
|
+
const email = this.services.email as EmailAdapter | undefined;
|
|
152
|
+
if (!email) {
|
|
153
|
+
return Err(new CommerceValidationError("No email adapter is configured."));
|
|
154
|
+
}
|
|
155
|
+
const rendered = await this.renderInvoiceHtml(orderId, actor, ctx);
|
|
156
|
+
if (!rendered.ok) return rendered;
|
|
157
|
+
|
|
158
|
+
const order = await this.loadOrder(orderId, actor, ctx);
|
|
159
|
+
if (!order.ok) return order;
|
|
160
|
+
|
|
161
|
+
await email.send({
|
|
162
|
+
template: "order-invoice",
|
|
163
|
+
to,
|
|
164
|
+
data: {
|
|
165
|
+
orgId: resolveOrgId(actor ?? ctx?.actor ?? null),
|
|
166
|
+
orderId,
|
|
167
|
+
orderNumber: order.value.orderNumber,
|
|
168
|
+
invoiceNumber: rendered.value.invoiceNumber,
|
|
169
|
+
grandTotal: order.value.grandTotal,
|
|
170
|
+
currency: order.value.currency,
|
|
171
|
+
html: rendered.value.html,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return Ok({ sent: true, invoiceNumber: rendered.value.invoiceNumber, to });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { Actor } from "../../auth/types.js";
|
|
|
4
4
|
import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
|
|
5
5
|
import type { PluginDb } from "../../kernel/database/plugin-types.js";
|
|
6
6
|
import type { TxContext } from "../../kernel/database/tx-context.js";
|
|
7
|
-
import { CommerceNotFoundError } from "../../kernel/errors.js";
|
|
7
|
+
import { CommerceNotFoundError, CommerceValidationError } from "../../kernel/errors.js";
|
|
8
8
|
import { runAfterHooks } from "../../kernel/hooks/executor.js";
|
|
9
9
|
import { createHookContext } from "../../kernel/hooks/create-context.js";
|
|
10
10
|
import type { HookRegistry } from "../../kernel/hooks/registry.js";
|
|
@@ -346,6 +346,144 @@ export class FulfillmentService {
|
|
|
346
346
|
return Ok(undefined);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Records a fulfillment (e.g. a shipment) for a subset of an order's line
|
|
351
|
+
* items, with optional carrier/tracking details. Supports partial
|
|
352
|
+
* fulfillment (per-line quantities) and multiple fulfillments per order.
|
|
353
|
+
*/
|
|
354
|
+
async createFulfillment(
|
|
355
|
+
input: {
|
|
356
|
+
orderId: string;
|
|
357
|
+
lineItems: Array<{ orderLineItemId: string; quantity: number }>;
|
|
358
|
+
carrier?: string;
|
|
359
|
+
trackingNumber?: string;
|
|
360
|
+
trackingUrl?: string;
|
|
361
|
+
type?: string;
|
|
362
|
+
status?: string;
|
|
363
|
+
metadata?: Record<string, unknown>;
|
|
364
|
+
},
|
|
365
|
+
actor?: Actor | null,
|
|
366
|
+
ctx?: TxContext,
|
|
367
|
+
): Promise<Result<FulfillmentRecord>> {
|
|
368
|
+
const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
|
|
369
|
+
const order = await this.deps.ordersRepository.findById(orgId, input.orderId, ctx);
|
|
370
|
+
if (!order) return Err(new CommerceNotFoundError("Order not found."));
|
|
371
|
+
|
|
372
|
+
const orderLines = await this.deps.ordersRepository.findLineItemsByOrderId(
|
|
373
|
+
input.orderId,
|
|
374
|
+
ctx,
|
|
375
|
+
);
|
|
376
|
+
const linesById = new Map(orderLines.map((li) => [li.id, li]));
|
|
377
|
+
|
|
378
|
+
// Validate every requested line before writing anything: it must belong
|
|
379
|
+
// to the order, and requested + already-fulfilled must not exceed ordered.
|
|
380
|
+
const alreadyFulfilled = new Map<string, number>();
|
|
381
|
+
for (const req of input.lineItems) {
|
|
382
|
+
const line = linesById.get(req.orderLineItemId);
|
|
383
|
+
if (!line) {
|
|
384
|
+
return Err(
|
|
385
|
+
new CommerceValidationError(
|
|
386
|
+
`Line item ${req.orderLineItemId} does not belong to this order.`,
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const fulfilled = await this.deps.repository.getFulfilledQuantity(
|
|
391
|
+
req.orderLineItemId,
|
|
392
|
+
ctx,
|
|
393
|
+
);
|
|
394
|
+
alreadyFulfilled.set(req.orderLineItemId, fulfilled);
|
|
395
|
+
if (fulfilled + req.quantity > line.quantity) {
|
|
396
|
+
return Err(
|
|
397
|
+
new CommerceValidationError(
|
|
398
|
+
`Cannot fulfill ${req.quantity} of "${line.title}" — only ${line.quantity - fulfilled} remaining.`,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const status = input.status ?? "shipped";
|
|
405
|
+
const created = await this.deps.repository.create(
|
|
406
|
+
{
|
|
407
|
+
orderId: order.id,
|
|
408
|
+
type: input.type ?? "physical",
|
|
409
|
+
status,
|
|
410
|
+
carrier: input.carrier ?? null,
|
|
411
|
+
trackingNumber: input.trackingNumber ?? null,
|
|
412
|
+
trackingUrl: input.trackingUrl ?? null,
|
|
413
|
+
...(status === "shipped" ? { shippedAt: new Date() } : {}),
|
|
414
|
+
...(status === "delivered" ? { deliveredAt: new Date() } : {}),
|
|
415
|
+
...(order.customerId != null ? { customerId: order.customerId } : {}),
|
|
416
|
+
metadata: input.metadata ?? {},
|
|
417
|
+
},
|
|
418
|
+
ctx,
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
await this.deps.repository.createLineItems(
|
|
422
|
+
input.lineItems.map((li) => ({
|
|
423
|
+
fulfillmentId: created.id,
|
|
424
|
+
orderLineItemId: li.orderLineItemId,
|
|
425
|
+
quantity: li.quantity,
|
|
426
|
+
})),
|
|
427
|
+
ctx,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await this.deps.repository.createEvent(
|
|
431
|
+
{
|
|
432
|
+
fulfillmentId: created.id,
|
|
433
|
+
eventType: "created",
|
|
434
|
+
toStatus: status,
|
|
435
|
+
...(actor?.userId !== undefined ? { actorId: actor.userId } : {}),
|
|
436
|
+
description: input.trackingNumber
|
|
437
|
+
? `Shipment recorded (${input.trackingNumber})`
|
|
438
|
+
: "Fulfillment recorded",
|
|
439
|
+
},
|
|
440
|
+
ctx,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
// Reflect per-line fulfillment progress on the order line items
|
|
444
|
+
for (const req of input.lineItems) {
|
|
445
|
+
const line = linesById.get(req.orderLineItemId)!;
|
|
446
|
+
const total = (alreadyFulfilled.get(req.orderLineItemId) ?? 0) + req.quantity;
|
|
447
|
+
await this.deps.ordersRepository.updateLineItem(
|
|
448
|
+
req.orderLineItemId,
|
|
449
|
+
{ fulfillmentStatus: total >= line.quantity ? "fulfilled" : "partial" },
|
|
450
|
+
ctx,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const record: FulfillmentRecord = {
|
|
455
|
+
...toServiceRecord(
|
|
456
|
+
created,
|
|
457
|
+
input.lineItems.map((li) => ({
|
|
458
|
+
id: "",
|
|
459
|
+
fulfillmentId: created.id,
|
|
460
|
+
orderLineItemId: li.orderLineItemId,
|
|
461
|
+
quantity: li.quantity,
|
|
462
|
+
})),
|
|
463
|
+
),
|
|
464
|
+
lineItems: input.lineItems.map((li) => ({
|
|
465
|
+
id: li.orderLineItemId,
|
|
466
|
+
title: linesById.get(li.orderLineItemId)?.title ?? "",
|
|
467
|
+
quantity: li.quantity,
|
|
468
|
+
})),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const afterHooks = this.deps.hooks.resolve(
|
|
472
|
+
"fulfillment.afterCreate",
|
|
473
|
+
) as AfterHook<FulfillmentRecord>[];
|
|
474
|
+
const hookCtx: HookContext = createHookContext({
|
|
475
|
+
actor: actor ?? ctx?.actor ?? null,
|
|
476
|
+
tx: ctx?.tx ?? null,
|
|
477
|
+
logger: createLogger("fulfillment"),
|
|
478
|
+
services: this.deps.services,
|
|
479
|
+
context: { moduleName: "fulfillment" },
|
|
480
|
+
database: { db: this.deps.database.db as PluginDb },
|
|
481
|
+
});
|
|
482
|
+
await runAfterHooks(afterHooks, null, record, "create", hookCtx);
|
|
483
|
+
|
|
484
|
+
return Ok(record);
|
|
485
|
+
}
|
|
486
|
+
|
|
349
487
|
async getByOrderId(
|
|
350
488
|
orderId: string,
|
|
351
489
|
ctx?: TxContext,
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
DrizzleDatabase,
|
|
5
5
|
DbOrTx,
|
|
6
6
|
} from "../../../kernel/database/drizzle-db.js";
|
|
7
|
-
import { orders, orderLineItems, orderStatusHistory } from "../schema.js";
|
|
7
|
+
import { orders, orderLineItems, orderNotes, orderRefunds, orderStatusHistory } from "../schema.js";
|
|
8
8
|
import { customers } from "../../customers/schema.js";
|
|
9
9
|
|
|
10
10
|
export interface OrderLookupRow {
|
|
@@ -26,6 +26,10 @@ export type OrderLineItem = typeof orderLineItems.$inferSelect;
|
|
|
26
26
|
export type OrderLineItemInsert = typeof orderLineItems.$inferInsert;
|
|
27
27
|
export type OrderStatusHistory = typeof orderStatusHistory.$inferSelect;
|
|
28
28
|
export type OrderStatusHistoryInsert = typeof orderStatusHistory.$inferInsert;
|
|
29
|
+
export type OrderRefund = typeof orderRefunds.$inferSelect;
|
|
30
|
+
export type OrderRefundInsert = typeof orderRefunds.$inferInsert;
|
|
31
|
+
export type OrderNote = typeof orderNotes.$inferSelect;
|
|
32
|
+
export type OrderNoteInsert = typeof orderNotes.$inferInsert;
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* OrdersRepository provides type-safe database operations for orders.
|
|
@@ -159,6 +163,24 @@ export class OrdersRepository {
|
|
|
159
163
|
return query;
|
|
160
164
|
}
|
|
161
165
|
|
|
166
|
+
async findByIdempotencyKey(
|
|
167
|
+
orgId: string,
|
|
168
|
+
idempotencyKey: string,
|
|
169
|
+
ctx?: TxContext,
|
|
170
|
+
): Promise<Order | undefined> {
|
|
171
|
+
const db = this.getDb(ctx);
|
|
172
|
+
const rows = await db
|
|
173
|
+
.select()
|
|
174
|
+
.from(orders)
|
|
175
|
+
.where(
|
|
176
|
+
and(
|
|
177
|
+
eq(orders.organizationId, orgId),
|
|
178
|
+
eq(orders.idempotencyKey, idempotencyKey),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
return rows[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
162
184
|
async create(data: OrderInsert, ctx?: TxContext): Promise<Order> {
|
|
163
185
|
const db = this.getDb(ctx);
|
|
164
186
|
const rows = await db.insert(orders).values(data).returning();
|
|
@@ -328,6 +350,110 @@ export class OrdersRepository {
|
|
|
328
350
|
return { order, lineItems };
|
|
329
351
|
}
|
|
330
352
|
|
|
353
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
354
|
+
// Line-level refunds (issue #52)
|
|
355
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
async createRefund(data: OrderRefundInsert, ctx?: TxContext): Promise<OrderRefund> {
|
|
358
|
+
const db = this.getDb(ctx);
|
|
359
|
+
const rows = await db.insert(orderRefunds).values(data).returning();
|
|
360
|
+
return rows[0]!;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async findRefundById(
|
|
364
|
+
orgId: string,
|
|
365
|
+
id: string,
|
|
366
|
+
ctx?: TxContext,
|
|
367
|
+
): Promise<OrderRefund | undefined> {
|
|
368
|
+
const db = this.getDb(ctx);
|
|
369
|
+
const rows = await db
|
|
370
|
+
.select()
|
|
371
|
+
.from(orderRefunds)
|
|
372
|
+
.where(and(eq(orderRefunds.organizationId, orgId), eq(orderRefunds.id, id)));
|
|
373
|
+
return rows[0];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async findRefundsByOrderId(orderId: string, ctx?: TxContext): Promise<OrderRefund[]> {
|
|
377
|
+
const db = this.getDb(ctx);
|
|
378
|
+
return db
|
|
379
|
+
.select()
|
|
380
|
+
.from(orderRefunds)
|
|
381
|
+
.where(eq(orderRefunds.orderId, orderId))
|
|
382
|
+
.orderBy(desc(orderRefunds.createdAt));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async markRefundUndone(
|
|
386
|
+
id: string,
|
|
387
|
+
undoneBy: string,
|
|
388
|
+
ctx?: TxContext,
|
|
389
|
+
): Promise<OrderRefund | undefined> {
|
|
390
|
+
const db = this.getDb(ctx);
|
|
391
|
+
const rows = await db
|
|
392
|
+
.update(orderRefunds)
|
|
393
|
+
.set({ status: "undone", undoneAt: new Date(), undoneBy })
|
|
394
|
+
.where(and(eq(orderRefunds.id, id), eq(orderRefunds.status, "completed")))
|
|
395
|
+
.returning();
|
|
396
|
+
return rows[0];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Sum of completed refunds performed by an operator for the local calendar
|
|
401
|
+
* day in the given timezone — the daily-cap accounting query.
|
|
402
|
+
*/
|
|
403
|
+
async sumRefundsByOperatorToday(
|
|
404
|
+
orgId: string,
|
|
405
|
+
performedBy: string,
|
|
406
|
+
timezone: string,
|
|
407
|
+
ctx?: TxContext,
|
|
408
|
+
): Promise<number> {
|
|
409
|
+
const db = this.getDb(ctx);
|
|
410
|
+
const result = await db.execute(sql`
|
|
411
|
+
SELECT COALESCE(sum(amount), 0)::int AS "total"
|
|
412
|
+
FROM order_refunds
|
|
413
|
+
WHERE organization_id = ${orgId}
|
|
414
|
+
AND performed_by = ${performedBy}
|
|
415
|
+
AND status = 'completed'
|
|
416
|
+
AND (created_at AT TIME ZONE ${timezone})::date = (now() AT TIME ZONE ${timezone})::date
|
|
417
|
+
`);
|
|
418
|
+
const rows = Array.isArray(result)
|
|
419
|
+
? (result as Record<string, unknown>[])
|
|
420
|
+
: ((result as { rows?: Record<string, unknown>[] }).rows ?? []);
|
|
421
|
+
const total = rows[0]?.total;
|
|
422
|
+
return typeof total === "bigint" ? Number(total) : ((total as number) ?? 0);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
426
|
+
// Order notes (issue #56)
|
|
427
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
async createNote(data: OrderNoteInsert, ctx?: TxContext): Promise<OrderNote> {
|
|
430
|
+
const db = this.getDb(ctx);
|
|
431
|
+
const rows = await db.insert(orderNotes).values(data).returning();
|
|
432
|
+
return rows[0]!;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async findNotesByOrderId(orderId: string, ctx?: TxContext): Promise<OrderNote[]> {
|
|
436
|
+
const db = this.getDb(ctx);
|
|
437
|
+
return db
|
|
438
|
+
.select()
|
|
439
|
+
.from(orderNotes)
|
|
440
|
+
.where(eq(orderNotes.orderId, orderId))
|
|
441
|
+
.orderBy(desc(orderNotes.pinned), desc(orderNotes.createdAt));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async deleteNote(orgId: string, orderId: string, noteId: string, ctx?: TxContext): Promise<boolean> {
|
|
445
|
+
const db = this.getDb(ctx);
|
|
446
|
+
const rows = await db
|
|
447
|
+
.delete(orderNotes)
|
|
448
|
+
.where(and(
|
|
449
|
+
eq(orderNotes.id, noteId),
|
|
450
|
+
eq(orderNotes.orderId, orderId),
|
|
451
|
+
eq(orderNotes.organizationId, orgId),
|
|
452
|
+
))
|
|
453
|
+
.returning();
|
|
454
|
+
return rows.length > 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
331
457
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
332
458
|
// Order Number Generation
|
|
333
459
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
|
2
3
|
import { organization } from "../../auth/auth-schema.js";
|
|
3
4
|
import { sellableEntities, variants } from "../catalog/schema.js";
|
|
4
5
|
|
|
@@ -17,6 +18,9 @@ export const orders = pgTable("orders", {
|
|
|
17
18
|
amountCaptured: integer("amount_captured"),
|
|
18
19
|
paymentIntentId: text("payment_intent_id"),
|
|
19
20
|
paymentMethodId: text("payment_method_id"),
|
|
21
|
+
// Client-supplied key making order creation safely retryable (offline POS
|
|
22
|
+
// queues, network retries) — replays return the original order.
|
|
23
|
+
idempotencyKey: text("idempotency_key"),
|
|
20
24
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
21
25
|
placedAt: timestamp("placed_at", { withTimezone: true }).defaultNow().notNull(),
|
|
22
26
|
fulfilledAt: timestamp("fulfilled_at", { withTimezone: true }),
|
|
@@ -28,6 +32,9 @@ export const orders = pgTable("orders", {
|
|
|
28
32
|
customerIdIdx: index("idx_orders_customer_id").on(table.customerId),
|
|
29
33
|
placedAtIdx: index("idx_orders_placed_at").on(table.placedAt),
|
|
30
34
|
paymentIntentIdx: index("idx_orders_payment_intent").on(table.paymentIntentId),
|
|
35
|
+
orgIdempotencyKeyUnique: uniqueIndex("orders_org_idempotency_key_unique")
|
|
36
|
+
.on(table.organizationId, table.idempotencyKey)
|
|
37
|
+
.where(sql`idempotency_key IS NOT NULL`),
|
|
31
38
|
}));
|
|
32
39
|
|
|
33
40
|
export const orderLineItems = pgTable("order_line_items", {
|
|
@@ -45,6 +52,9 @@ export const orderLineItems = pgTable("order_line_items", {
|
|
|
45
52
|
totalPrice: integer("total_price").notNull(),
|
|
46
53
|
taxAmount: integer("tax_amount").notNull().default(0),
|
|
47
54
|
discountAmount: integer("discount_amount").notNull().default(0),
|
|
55
|
+
// Units already refunded on this line (issue #52) — line-level refund REST
|
|
56
|
+
// rejects refunds beyond `quantity - refundedQuantity`.
|
|
57
|
+
refundedQuantity: integer("refunded_quantity").notNull().default(0),
|
|
48
58
|
fulfillmentStatus: text("fulfillment_status").notNull().default("unfulfilled"),
|
|
49
59
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
50
60
|
}, (table) => [
|
|
@@ -52,6 +62,49 @@ export const orderLineItems = pgTable("order_line_items", {
|
|
|
52
62
|
index("idx_order_line_items_entity_id").on(table.entityId),
|
|
53
63
|
]);
|
|
54
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Line-level refunds (issue #52). One row per refund operation — the ledger
|
|
67
|
+
* behind the daily refund cap and the undo window. `lines` records which
|
|
68
|
+
* line items and quantities the refund covered so undo can restore them.
|
|
69
|
+
*/
|
|
70
|
+
export const orderRefunds = pgTable("order_refunds", {
|
|
71
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
72
|
+
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
|
|
73
|
+
orderId: uuid("order_id")
|
|
74
|
+
.references(() => orders.id, { onDelete: "cascade" })
|
|
75
|
+
.notNull(),
|
|
76
|
+
amount: integer("amount").notNull(),
|
|
77
|
+
reason: text("reason"),
|
|
78
|
+
lines: jsonb("lines").$type<Array<{ lineItemId: string; quantity: number; amount: number }>>().notNull(),
|
|
79
|
+
performedBy: text("performed_by").notNull(),
|
|
80
|
+
status: text("status", { enum: ["completed", "undone"] }).notNull().default("completed"),
|
|
81
|
+
undoneAt: timestamp("undone_at", { withTimezone: true }),
|
|
82
|
+
undoneBy: text("undone_by"),
|
|
83
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
84
|
+
}, (table) => [
|
|
85
|
+
index("idx_order_refunds_order_id").on(table.orderId),
|
|
86
|
+
index("idx_order_refunds_org_performed_by").on(table.organizationId, table.performedBy, table.createdAt),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Operator annotations on orders (issue #56) — "customer will pick up
|
|
91
|
+
* Thursday". Merged into GET /orders/{id}/timeline alongside status history
|
|
92
|
+
* and refund ledger events.
|
|
93
|
+
*/
|
|
94
|
+
export const orderNotes = pgTable("order_notes", {
|
|
95
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
96
|
+
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
|
|
97
|
+
orderId: uuid("order_id")
|
|
98
|
+
.references(() => orders.id, { onDelete: "cascade" })
|
|
99
|
+
.notNull(),
|
|
100
|
+
author: text("author").notNull(),
|
|
101
|
+
body: text("body").notNull(),
|
|
102
|
+
pinned: boolean("pinned").notNull().default(false),
|
|
103
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
104
|
+
}, (table) => [
|
|
105
|
+
index("idx_order_notes_order_id").on(table.orderId),
|
|
106
|
+
]);
|
|
107
|
+
|
|
55
108
|
export const orderStatusHistory = pgTable("order_status_history", {
|
|
56
109
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
57
110
|
orderId: uuid("order_id")
|