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:
@@ -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 & reissue
|
Cancel & reissue
|
||||||
</s-button>
|
</s-button>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user