Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-28 11:30:21 +00:00
1 parent 379cc26dcd
commit 188db4b3c1
15 files changed
+2035 -12

No files matched your search

+101 -5
View File
@@ -116,14 +116,110 @@ test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz',
assert.equal(dated.cancel_at, 1800000000)
// nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert
st.nextSubscriptionUpdate = { latest_invoice: { status: 'paid', hosted_invoice_url: 'https://pay.example/inv' } }
const once = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
assert.equal(once.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(once.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).latest_invoice, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).latest_invoice, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
})
// Task 9: mit pending_update im einmaligen Zusatz verhaelt sich der Nachbau wie Stripe bei einer ausstehenden
// Aktualisierung (Zahlung nicht erfolgt): das Abo bleibt unveraendert, die Aenderung wartet, bis ein Test sie
// mit applyPendingUpdate uebernimmt (= Rechnung bezahlt)
test('Stripe-Nachbau: ausstehende Aktualisierung bleibt zurückgehalten, bis sie übernommen wird', async () => {
const stripe = getStripe()
st.prices.set('price_b', { id: 'price_b', object: 'price', active: true, lookup_key: 'lookup_b', unit_amount: 500 })
st.subscriptions.set('sub_p', {
id: 'sub_p',
object: 'subscription',
status: 'active',
metadata: { productKey: 'hosting' },
items: { object: 'list', data: [{ id: 'si_p', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
st.nextSubscriptionUpdate = { pending_update: { expires_at: 1 }, latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
const pending = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
const pending = await stripe.subscriptions.update('sub_p', {
items: [{ id: 'si_p', price: 'price_b' }],
metadata: { productKey: 'wartung' },
payment_behavior: 'pending_if_incomplete',
})
assert.deepEqual(pending.pending_update, { expires_at: 1 })
assert.equal(pending.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(pending.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).pending_update, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).pending_update, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
// Wie Stripe: Antwort und gespeichertes Abo zeigen den alten Stand
assert.equal(pending.metadata.productKey, 'hosting')
assert.equal(pending.items.data[0].price.id, 'price_0')
const stored = await stripe.subscriptions.retrieve('sub_p')
assert.equal(stored.metadata.productKey, 'hosting')
assert.equal(stored.pending_update, undefined)
// Bezahlt: die zurueckgehaltene Aenderung wird uebernommen
const applied = st.applyPendingUpdate('sub_p')
assert.equal(applied.metadata.productKey, 'wartung')
assert.equal((await stripe.subscriptions.retrieve('sub_p')).items.data[0].price.id, 'price_b')
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
})
// Wie Stripe (docs.stripe.com/billing/subscriptions/pending-updates, Stand 28.09.2026): mit
// pending_if_incomplete nur die unterstuetzten Parameter; Metadaten hoechstens 40 Zeichen je Schluessel und
// 500 je Wert
test('Stripe-Nachbau: pending_if_incomplete nur mit unterstützten Parametern, Metadaten mit Stripe-Grenzen', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_q', {
id: 'sub_q',
object: 'subscription',
status: 'active',
metadata: {},
items: { object: 'list', data: [{ id: 'si_q', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', description: 'nicht erlaubt' }),
(e) => e.statusCode === 400 && /description/.test(e.message)
)
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', items: [{ id: 'si_q', metadata: { x: '1' } }] }),
(e) => e.statusCode === 400 && /metadata/.test(e.message)
)
const ok = await stripe.subscriptions.update('sub_q', {
payment_behavior: 'pending_if_incomplete',
items: [{ id: 'si_q', quantity: 2 }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: 1790000000,
metadata: { a: 'b' },
discounts: [{ coupon: 'X' }],
expand: ['latest_invoice'],
})
assert.equal(ok.items.data[0].quantity, 2)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(501) } }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { ['k'.repeat(41)]: '1' } }), (e) => e.statusCode === 400)
assert.equal((await stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(500) } })).metadata.a.length, 500)
})
// F25: Mails lassen sich in Tests pruefen (nodemailer jsonTransport statt SMTP); null schaltet zurueck
test('Mail-Senke: Kunden- und Team-Mails landen in der Senke, null schaltet zurück', async () => {
const { mailSink } = await import('./support/mailSink.js')
const { sendAdminNotice, sendCustomerMail, setMailTransportForTests } = await import('../server/services/mailer.js')
const sink = mailSink()
setMailTransportForTests(sink)
try {
await sendCustomerMail('k@example.invalid', {
template: 'K10',
values: { name: 'K', antrag_datum: '01.10.2026', antrag_nr: 'A1', begruendung: 'Nachweis fehlt' },
attachments: [{ filename: 'a.pdf', content: Buffer.from('PDF'), contentType: 'application/pdf' }],
})
assert.deepEqual(await sendAdminNotice({ template: 'T06', values: { email: 'k@example.invalid', stripe_abo_id: 'sub_1' } }), { sent: true })
assert.equal(sink.sent.length, 2)
const [customerMail, notice] = sink.sent
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.equal(customerMail.from.name, 'WEBklar')
assert.match(customerMail.text, /Nachweis fehlt/)
assert.deepEqual(customerMail.attachments.map((a) => a.filename), ['a.pdf'])
assert.equal(notice.from.name, 'WEBklar Kundenportal')
assert.match(notice.subject, /^\[Abo\]/)
} finally {
setMailTransportForTests(null)
}
await assert.rejects(sendCustomerMail('k@example.invalid', { template: 'K10', values: {} }), /SMTP nicht konfiguriert/)
})
// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert