Stundenkonto: Arbeitszeit nur für bezahlte Wartungs-Monate, nicht für andere Abo-Rechnungen
Die Rechnung wird frisch geladen; eine Stunde bringt nur eine reguläre (nicht anteilige) Wartungs-Zeile über 0 €. Preis-IDs werden einmal bei Stripe nachgeschlagen und zwischengespeichert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d097704d49
commit
51fc2434e5
2 files changed
+133
-9
No files matched your search
@@ -8,7 +8,7 @@ import {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
import { lateStartAt } from './deadlines.js'
|
import { lateStartAt } from './deadlines.js'
|
||||||
import { describeItems } from './products.js'
|
import { creditMinutesForInvoice, describeItems, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wartungs-Abo ueber Stripe Billing.
|
* Wartungs-Abo ueber Stripe Billing.
|
||||||
@@ -255,20 +255,44 @@ function subscriptionIdOfInvoice(invoice) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
|
// Preis-ID -> Produkt ('wartung' | 'hosting' | 'mailStorage' | null); Preise ändern ihren lookup_key nicht
|
||||||
|
const priceKeyCache = new Map()
|
||||||
|
|
||||||
|
/** Produkte zu Preis-IDs; unbekannte IDs einmal bei Stripe nachschlagen (gelöscht/404 -> null). */
|
||||||
|
export async function resolvePriceKeys(priceIds) {
|
||||||
|
const keys = new Map()
|
||||||
|
for (const id of priceIds) {
|
||||||
|
if (!priceKeyCache.has(id)) {
|
||||||
|
const price = await getStripe()
|
||||||
|
.prices.retrieve(id)
|
||||||
|
.catch((err) => {
|
||||||
|
if (err?.statusCode === 404) return null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
priceKeyCache.set(id, productKeyForLookupKey(price?.lookup_key))
|
||||||
|
}
|
||||||
|
keys.set(id, priceKeyCache.get(id))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
|
||||||
export async function grantCreditForInvoice(invoice) {
|
export async function grantCreditForInvoice(invoice) {
|
||||||
const ref = subscriptionIdOfInvoice(invoice)
|
const ref = subscriptionIdOfInvoice(invoice)
|
||||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||||
// subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine
|
// Frisch laden: Die Webhook-Nutzlast hat die API-Version des Webhook-Endpunkts, nicht die des SDK.
|
||||||
// Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen
|
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
|
||||||
// haben eine Zwischensumme von 0 € und bringen keine Stunde.
|
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
|
||||||
if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' }
|
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
|
||||||
|
const fresh = await getStripe().invoices.retrieve(invoice.id)
|
||||||
|
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
|
||||||
|
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
|
||||||
|
|
||||||
const sub = await syncSubscription(subscriptionId)
|
const sub = await syncSubscription(subscriptionId)
|
||||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||||
|
|
||||||
const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000)
|
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
|
||||||
try {
|
try {
|
||||||
await createDocument(
|
await createDocument(
|
||||||
config.collections.hourCredits,
|
config.collections.hourCredits,
|
||||||
@@ -276,11 +300,11 @@ export async function grantCreditForInvoice(invoice) {
|
|||||||
customerId: sub.customerId,
|
customerId: sub.customerId,
|
||||||
stripeSubscriptionId: subscriptionId,
|
stripeSubscriptionId: subscriptionId,
|
||||||
stripeInvoiceId: invoice.id,
|
stripeInvoiceId: invoice.id,
|
||||||
minutes: config.abo.minutesPerMonth,
|
minutes,
|
||||||
grantedAt: grantedAt.toISOString(),
|
grantedAt: grantedAt.toISOString(),
|
||||||
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
||||||
source: 'stripe_invoice',
|
source: 'stripe_invoice',
|
||||||
note: invoice.number ? `Rechnung ${invoice.number}` : '',
|
note: fresh.number ? `Rechnung ${fresh.number}` : '',
|
||||||
},
|
},
|
||||||
invoice.id
|
invoice.id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { test, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: { subscriptions: schemaKeys('subscriptions'), hourCredits: schemaKeys('hourCredits') },
|
||||||
|
})
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { grantCreditForInvoice } = await import('../server/services/stripeBilling.js')
|
||||||
|
|
||||||
|
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
|
||||||
|
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
|
||||||
|
st.prices.set('price_h', price('price_h', 'webklar_hosting_monatlich_1500', 1500))
|
||||||
|
|
||||||
|
const sub = (id, priceObj) => ({
|
||||||
|
id,
|
||||||
|
object: 'subscription',
|
||||||
|
status: 'active',
|
||||||
|
customer: 'cus_1',
|
||||||
|
start_date: 1790000000,
|
||||||
|
current_period_end: 1792600000,
|
||||||
|
metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' },
|
||||||
|
items: { object: 'list', data: [{ id: `si_${id}`, object: 'subscription_item', quantity: 1, price: priceObj }] },
|
||||||
|
})
|
||||||
|
st.subscriptions.set('sub_w', sub('sub_w', st.prices.get('price_w')))
|
||||||
|
st.subscriptions.set('sub_h', sub('sub_h', st.prices.get('price_h')))
|
||||||
|
|
||||||
|
// Frisch geladene Rechnung in der Form der SDK-API-Version (2025-10-29.clover)
|
||||||
|
const inv = (id, subscription, lines) => ({
|
||||||
|
id,
|
||||||
|
object: 'invoice',
|
||||||
|
number: `WK-${id}`,
|
||||||
|
created: 1790000000,
|
||||||
|
status_transitions: { paid_at: 1790000100 },
|
||||||
|
parent: { type: 'subscription_details', subscription_details: { subscription } },
|
||||||
|
lines: { object: 'list', data: lines },
|
||||||
|
})
|
||||||
|
const regular = (priceId, amount) => ({
|
||||||
|
amount,
|
||||||
|
pricing: { type: 'price_details', price_details: { price: priceId } },
|
||||||
|
parent: { type: 'subscription_item_details', subscription_item_details: { proration: false } },
|
||||||
|
})
|
||||||
|
// Webhook-Nutzlast (Auszug), wie sie heute ankommt
|
||||||
|
const payload = (id, subscription, subtotal) => ({
|
||||||
|
id,
|
||||||
|
subtotal,
|
||||||
|
created: 1790000000,
|
||||||
|
status_transitions: { paid_at: 1790000100 },
|
||||||
|
parent: { subscription_details: { subscription } },
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wartungs-Rechnung: 60 Minuten, genau einmal', async () => {
|
||||||
|
st.invoices.set('in_1', inv('in_1', 'sub_w', [regular('price_w', 5000)]))
|
||||||
|
const r = await grantCreditForInvoice(payload('in_1', 'sub_w', 5000))
|
||||||
|
assert.equal(r.granted, true)
|
||||||
|
const credit = aw.docs('hourCredits').get('in_1')
|
||||||
|
assert.equal(credit.minutes, 60)
|
||||||
|
assert.equal(credit.customerId, 'c1')
|
||||||
|
assert.equal(credit.grantedAt, new Date(1790000100 * 1000).toISOString())
|
||||||
|
assert.deepEqual(await grantCreditForInvoice(payload('in_1', 'sub_w', 5000)), { skipped: 'bereits gutgeschrieben' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hosting-Rechnung: keine Stunde', async () => {
|
||||||
|
st.invoices.set('in_2', inv('in_2', 'sub_h', [regular('price_h', 1500)]))
|
||||||
|
assert.deepEqual(await grantCreditForInvoice(payload('in_2', 'sub_h', 1500)), { skipped: 'keine Wartungs-Leistung' })
|
||||||
|
assert.equal(aw.docs('hourCredits').has('in_2'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast', async () => {
|
||||||
|
// Nutzlast in alter Form (ohne pricing/parent) behauptet Wartung, frisch geladen ist es Hosting
|
||||||
|
st.invoices.set('in_3', inv('in_3', 'sub_h', [regular('price_h', 1500)]))
|
||||||
|
const oldPayload = {
|
||||||
|
id: 'in_3',
|
||||||
|
subscription: 'sub_h',
|
||||||
|
subtotal: 5000,
|
||||||
|
created: 1790000000,
|
||||||
|
status_transitions: { paid_at: 1790000100 },
|
||||||
|
lines: { data: [{ amount: 5000, price: { id: 'price_w' }, proration: false }] },
|
||||||
|
}
|
||||||
|
assert.deepEqual(await grantCreditForInvoice(oldPayload), { skipped: 'keine Wartungs-Leistung' })
|
||||||
|
assert.equal(aw.docs('hourCredits').has('in_3'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preis-Zuordnung wird zwischengespeichert', async () => {
|
||||||
|
st.invoices.set('in_4', inv('in_4', 'sub_w', [regular('price_w', 5000)]))
|
||||||
|
await grantCreditForInvoice(payload('in_4', 'sub_w', 5000))
|
||||||
|
assert.equal(aw.docs('hourCredits').get('in_4').minutes, 60)
|
||||||
|
assert.equal(st.calls.filter((c) => c.path === '/v1/prices/price_w').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Unbekannte Preis-ID (404): keine Stunde, kein Absturz', async () => {
|
||||||
|
st.invoices.set('in_5', inv('in_5', 'sub_w', [regular('price_weg', 5000)]))
|
||||||
|
assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_w', 5000)), { skipped: 'keine Wartungs-Leistung' })
|
||||||
|
assert.equal(aw.docs('hourCredits').has('in_5'), false)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user