feat(invoice): add Send invoice email action

Adds a Send button to the order action extension and a corresponding
"send" op to /api/orders/:orderId/invoice. Generates the invoice on
demand if missing, then sends via the configured SMTP.
This commit is contained in:
Gerhard Scheikl
2026-05-08 15:27:16 +02:00
parent a67fc0767e
commit edd72f2776
7 changed files with 49 additions and 2 deletions
@@ -3,6 +3,7 @@ import { authenticate } from "../shopify.server";
import db from "../db.server"; import db from "../db.server";
import { generateInvoice } from "../services/invoice/generateInvoice.server"; import { generateInvoice } from "../services/invoice/generateInvoice.server";
import { cancelAndReissue } from "../services/invoice/cancelAndReissue.server"; import { cancelAndReissue } from "../services/invoice/cancelAndReissue.server";
import { sendInvoiceEmail } from "../services/invoice/email.server";
/** /**
* GET /api/orders/:orderId/invoice → returns latest invoice metadata + history * GET /api/orders/:orderId/invoice → returns latest invoice metadata + history
@@ -60,6 +61,50 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return cors(Response.json({ ok: true, op, ...result })); return cors(Response.json({ ok: true, op, ...result }));
} }
if (op === "send") {
const orderGid = orderId.startsWith("gid://")
? orderId
: `gid://shopify/Order/${orderId}`;
let invoice = await db.invoice.findFirst({
where: {
shopDomain: session.shop,
orderId: orderGid,
kind: "invoice",
cancelledAt: null,
},
orderBy: [{ version: "desc" }, { createdAt: "desc" }],
});
if (!invoice) {
const generated = await generateInvoice({
shopDomain: session.shop,
admin,
orderId,
});
invoice = await db.invoice.findUnique({ where: { id: generated.invoiceId } });
}
if (!invoice) throw new Error("Failed to materialise an invoice for this order.");
const sendResult = await sendInvoiceEmail({
shopDomain: session.shop,
invoiceId: invoice.id,
});
if (!sendResult.ok) {
return cors(
Response.json(
{ ok: false, op: "send", error: sendResult.errorMessage ?? "Email send failed." },
{ status: 422 },
),
);
}
return cors(
Response.json({
ok: true,
op: "send",
invoiceNumber: invoice.invoiceNumber,
toAddress: sendResult.toAddress,
}),
);
}
const result = await generateInvoice({ const result = await generateInvoice({
shopDomain: session.shop, shopDomain: session.shop,
admin, admin,
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ function Extension() {
const orderGid: string | undefined = data?.selected?.[0]?.id; const orderGid: string | undefined = data?.selected?.[0]?.id;
const orderId = orderGid ? orderGid.split("/").pop() : undefined; const orderId = orderGid ? orderGid.split("/").pop() : undefined;
async function trigger(action: "generate" | "cancel_reissue") { async function trigger(action: "generate" | "cancel_reissue" | "send") {
if (!orderId) { if (!orderId) {
setError("No order selected"); setError("No order selected");
return; return;
@@ -47,6 +47,9 @@ function Extension() {
<s-button slot="primary-action" onClick={() => trigger("generate")} disabled={busy}> <s-button slot="primary-action" onClick={() => trigger("generate")} disabled={busy}>
Generate / regenerate Generate / regenerate
</s-button> </s-button>
<s-button slot="secondary-actions" onClick={() => trigger("send")} disabled={busy}>
Send invoice email
</s-button>
<s-button slot="secondary-actions" tone="critical" onClick={() => trigger("cancel_reissue")} disabled={busy}> <s-button slot="secondary-actions" tone="critical" onClick={() => trigger("cancel_reissue")} disabled={busy}>
Cancel &amp; reissue Cancel &amp; reissue
</s-button> </s-button>
-1
View File
@@ -39,5 +39,4 @@ redirect_urls = [
] ]
[build] [build]
include_config_on_deploy = true
automatically_update_urls_on_dev = true automatically_update_urls_on_dev = true